diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c1cbd5621e..5f6c6870ae 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; +import { experimentCommand } from '../ui/commands/experimentCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { footerCommand } from '../ui/commands/footerCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -133,6 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, + experimentCommand, ...(this.config?.getExtensionsEnabled() === false ? [ { diff --git a/packages/cli/src/ui/commands/experimentCommand.test.ts b/packages/cli/src/ui/commands/experimentCommand.test.ts new file mode 100644 index 0000000000..5e34073c37 --- /dev/null +++ b/packages/cli/src/ui/commands/experimentCommand.test.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { experimentCommand } from './experimentCommand.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; + +describe('experimentCommand', () => { + let mockContext: { + services: { + config: { + getExperimentValue: vi.Mock; + }; + settings: { + merged: { + experimental: Record; + }; + setValue: vi.Mock; + }; + }; + ui: { + addItem: vi.Mock; + }; + }; + + beforeEach(() => { + mockContext = { + services: { + config: { + getExperimentValue: vi.fn(), + }, + settings: { + merged: { + experimental: {}, + }, + setValue: vi.fn(), + }, + }, + ui: { + addItem: vi.fn(), + }, + }; + }); + + it('should have the correct name and description', () => { + expect(experimentCommand.name).toBe('experiment'); + expect(experimentCommand.description).toBe('Manage experimental features'); + expect(experimentCommand.kind).toBe(CommandKind.BUILT_IN); + }); + + describe('list sub-command', () => { + const listCommand = experimentCommand.subCommands?.find( + (c) => c.name === 'list', + ); + + it('should list experiments', async () => { + mockContext.services.config.getExperimentValue.mockReturnValue(true); + await listCommand?.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('enable-preview'), + }), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Value: true'), + }), + ); + }); + }); + + describe('set sub-command', () => { + const setCommand = experimentCommand.subCommands?.find( + (c) => c.name === 'set', + ); + + it('should set a boolean experiment', async () => { + await setCommand?.action!(mockContext, 'enable-preview true'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'experimental', + expect.objectContaining({ + 'enable-preview': true, + }), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining( + 'Experiment enable-preview set to true', + ), + }), + ); + }); + + it('should set a number experiment', async () => { + await setCommand?.action!(mockContext, 'classifier-threshold 0.5'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'experimental', + expect.objectContaining({ + 'classifier-threshold': 0.5, + }), + ); + }); + + it('should show error for unknown experiment', async () => { + await setCommand?.action!(mockContext, 'unknown-exp true'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Unknown experiment: unknown-exp'), + }), + ); + }); + }); + + describe('unset sub-command', () => { + const unsetCommand = experimentCommand.subCommands?.find( + (c) => c.name === 'unset', + ); + + it('should unset an experiment', async () => { + mockContext.services.settings.merged.experimental = { + 'enable-preview': true, + }; + await unsetCommand?.action!(mockContext, 'enable-preview'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'experimental', + {}, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining( + 'Local override for experiment enable-preview removed', + ), + }), + ); + }); + + it('should show error if no override exists', async () => { + await unsetCommand?.action!(mockContext, 'enable-preview'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining( + 'No local override found for experiment: enable-preview', + ), + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/experimentCommand.ts b/packages/cli/src/ui/commands/experimentCommand.ts new file mode 100644 index 0000000000..2a6aca44b9 --- /dev/null +++ b/packages/cli/src/ui/commands/experimentCommand.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ExperimentMetadata, + getExperimentFlagIdFromName, + getExperimentFlagName, +} from '@google/gemini-cli-core'; +import { + type CommandContext, + CommandKind, + type SlashCommand, +} from './types.js'; +import { MessageType } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; + +const listExperimentsCommand: SlashCommand = { + name: 'list', + description: 'List all available experiments and their current values', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + const { config } = context.services; + if (!config) return; + + const entries = Object.entries(ExperimentMetadata); + if (entries.length === 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: 'No experiments available.', + }); + return; + } + + let output = 'Available Experiments:\n\n'; + for (const [idStr, metadata] of entries) { + const id = parseInt(idStr, 10); + const name = getExperimentFlagName(id) || `ID: ${id}`; + const value = config.getExperimentValue(id); + output += `${name} (${metadata.type})\n`; + output += ` Value: ${value}\n`; + output += ` Description: ${metadata.description}\n`; + output += ` Default: ${metadata.defaultValue}\n\n`; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: output.trim(), + }); + }, +}; + +const setExperimentCommand: SlashCommand = { + name: 'set', + description: + 'Set a local override for an experiment. Usage: /experiment set ', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext, args: string) => { + const parts = args.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /experiment set ', + }); + return; + } + + const name = parts[0]; + const rawValue = parts[1]; + const id = getExperimentFlagIdFromName(name); + + if (id === undefined) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Unknown experiment: ${name}`, + }); + return; + } + + const metadata = ExperimentMetadata[id]; + let value: boolean | number | string; + + if (metadata.type === 'boolean') { + if (rawValue === 'true' || rawValue === 'on') value = true; + else if (rawValue === 'false' || rawValue === 'off') value = false; + else { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Invalid boolean value: ${rawValue}. Use true/false or on/off.`, + }); + return; + } + } else if (metadata.type === 'number') { + value = Number(rawValue); + if (isNaN(value)) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Invalid number value: ${rawValue}`, + }); + return; + } + } else { + value = rawValue; + } + + const { settings } = context.services; + const currentExperimental = { + ...((settings.merged.experimental as Record) || {}), + }; + currentExperimental[name] = value; + + settings.setValue(SettingScope.User, 'experimental', currentExperimental); + + context.ui.addItem({ + type: MessageType.INFO, + text: `Experiment ${name} set to ${value} (persisted in user settings).`, + }); + }, +}; + +const unsetExperimentCommand: SlashCommand = { + name: 'unset', + description: + 'Remove a local override for an experiment. Usage: /experiment unset ', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext, args: string) => { + const name = args.trim(); + if (!name) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /experiment unset ', + }); + return; + } + + const { settings } = context.services; + const currentExperimental = { + ...((settings.merged.experimental as Record) || {}), + }; + + if (!(name in currentExperimental)) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `No local override found for experiment: ${name}`, + }); + return; + } + + delete currentExperimental[name]; + settings.setValue(SettingScope.User, 'experimental', currentExperimental); + + context.ui.addItem({ + type: MessageType.INFO, + text: `Local override for experiment ${name} removed.`, + }); + }, +}; + +export const experimentCommand: SlashCommand = { + name: 'experiment', + description: 'Manage experimental features', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + listExperimentsCommand, + setExperimentCommand, + unsetExperimentCommand, + ], + action: async (context: CommandContext, args: string) => + listExperimentsCommand.action!(context, args), +}; diff --git a/packages/core/index.ts b/packages/core/index.ts index 1d5dce60d3..f26941e4a1 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -46,5 +46,10 @@ export * from './src/utils/googleQuotaErrors.js'; export type { GoogleApiError } from './src/utils/googleErrors.js'; export { getCodeAssistServer } from './src/code_assist/codeAssist.js'; export { getExperiments } from './src/code_assist/experiments/experiments.js'; -export { ExperimentFlags } from './src/code_assist/experiments/flagNames.js'; +export { + ExperimentFlags, + ExperimentMetadata, + getExperimentFlagName, + getExperimentFlagIdFromName, +} from './src/code_assist/experiments/flagNames.js'; export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js'; diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts index 61ac949b8d..7333dac573 100644 --- a/packages/core/src/code_assist/experiments/flagNames.ts +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -100,3 +100,11 @@ export function getExperimentFlagName(flagId: number): string | undefined { } return undefined; } + +/** + * Gets the ID of an experiment flag from its kebab-case name. + */ +export function getExperimentFlagIdFromName(name: string): number | undefined { + const constantName = name.toUpperCase().replace(/-/g, '_'); + return (ExperimentFlags as Record)[constantName]; +} diff --git a/packages/core/src/config/config.experiment.test.ts b/packages/core/src/config/config.experiment.test.ts index 19306720ca..b044b35030 100644 --- a/packages/core/src/config/config.experiment.test.ts +++ b/packages/core/src/config/config.experiment.test.ts @@ -20,6 +20,7 @@ describe('Config getExperimentValue', () => { targetDir, cwd, model, + debugMode: false, experimentalCliArgs: { 'enable-preview': true }, experimentalSettings: { 'enable-preview': false }, experiments: { @@ -41,6 +42,7 @@ describe('Config getExperimentValue', () => { targetDir: process.cwd(), cwd: process.cwd(), model, + debugMode: false, experimentalSettings: { 'enable-preview': true }, experiments: { flags: { @@ -61,6 +63,7 @@ describe('Config getExperimentValue', () => { targetDir: process.cwd(), cwd: process.cwd(), model, + debugMode: false, experiments: { flags: { [ExperimentFlags.ENABLE_PREVIEW]: { boolValue: true }, @@ -80,6 +83,7 @@ describe('Config getExperimentValue', () => { targetDir: process.cwd(), cwd: process.cwd(), model, + debugMode: false, }); // Default for ENABLE_PREVIEW is false @@ -94,6 +98,7 @@ describe('Config getExperimentValue', () => { targetDir: process.cwd(), cwd: process.cwd(), model, + debugMode: false, experimentalCliArgs: { 'classifier-threshold': 0.8 }, }); @@ -108,6 +113,7 @@ describe('Config getExperimentValue', () => { targetDir: process.cwd(), cwd: process.cwd(), model, + debugMode: false, experiments: { flags: { [ExperimentFlags.CLASSIFIER_THRESHOLD]: { stringValue: '0.7' },