mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-15 05:47:18 -07:00
feat(cli): add /experiment command to list and manage experimental features
This commit is contained in:
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <name> <value>',
|
||||
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 <name> <value>',
|
||||
});
|
||||
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<string, unknown>) || {}),
|
||||
};
|
||||
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 <name>',
|
||||
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 <name>',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { settings } = context.services;
|
||||
const currentExperimental = {
|
||||
...((settings.merged.experimental as Record<string, unknown>) || {}),
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, number>)[constantName];
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user