feat(cli): add /experiment command to list and manage experimental features

This commit is contained in:
mkorwel
2026-02-19 16:49:49 -06:00
committed by Matt Korwel
parent bc8acdc309
commit 8ce3de1187
6 changed files with 365 additions and 1 deletions
@@ -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),
};
+6 -1
View File
@@ -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' },