feat(core): implement feature lifecycle management (Alpha, Beta, GA)

This commit is contained in:
Jerop Kipruto
2026-02-16 23:48:45 -05:00
parent 6aa6630137
commit 04f22a51b1
23 changed files with 1678 additions and 60 deletions
@@ -12,7 +12,7 @@ import {
configureSpecificSetting,
getExtensionManager,
} from './utils.js';
import { loadSettings } from '../../config/settings.js';
import { loadSettings, isFeatureEnabled } from '../../config/settings.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
@@ -45,10 +45,10 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
const { name, setting, scope } = args;
const settings = loadSettings(process.cwd()).merged;
if (!(settings.experimental?.extensionConfig ?? true)) {
if (!isFeatureEnabled(settings, 'extensionConfig')) {
coreEvents.emitFeedback(
'error',
'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.',
'Extension configuration is currently disabled. Enable it by setting "features.extensionConfig" to true.',
);
await exitCli();
return;
+56
View File
@@ -2647,6 +2647,62 @@ describe('loadCliConfig approval mode', () => {
expect(plansDir).toContain('.custom-plans');
});
describe('Feature Gates', () => {
it('should parse --feature-gates CLI flag', async () => {
process.argv = [
'node',
'script.js',
'--feature-gates',
'plan=true,enableAgents=false',
];
const argv = await parseArguments(createTestMergedSettings());
expect(argv.featureGates).toBe('plan=true,enableAgents=false');
});
it('should respect "features" setting in loadCliConfig', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
features: { plan: true },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should prioritize --feature-gates flag over settings', async () => {
process.argv = ['node', 'script.js', '--feature-gates', 'plan=true'];
const settings = createTestMergedSettings({
features: { plan: false },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should allow plan approval mode when features.plan is enabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const settings = createTestMergedSettings({
features: { plan: true },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
it('should throw error when --approval-mode=plan is used but features.plan is disabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const settings = createTestMergedSettings({
features: { plan: false },
});
const argv = await parseArguments(settings);
await expect(
loadCliConfig(settings, 'test-session', argv),
).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
});
});
// --- Untrusted Folder Scenarios ---
describe('when folder is NOT trusted', () => {
beforeEach(() => {
+24 -9
View File
@@ -44,6 +44,7 @@ import {
type MergedSettings,
saveModelChange,
loadSettings,
isFeatureEnabled,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -78,6 +79,7 @@ export interface CliArgs {
allowedTools: string[] | undefined;
acp?: boolean;
experimentalAcp?: boolean;
featureGates?: string | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -182,6 +184,12 @@ export async function parseArguments(
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
})
.option('feature-gates', {
type: 'string',
nargs: 1,
description:
'Comma-separated list of feature key-value pairs (e.g. "Foo=true,Bar=false")',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -328,7 +336,7 @@ export async function parseArguments(
return true;
});
if (settings.experimental?.extensionManagement) {
if (isFeatureEnabled(settings, 'extensionManagement')) {
yargsInstance.command(extensionsCommand);
}
@@ -486,7 +494,7 @@ export async function loadCliConfig(
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental?.jitContext ?? false;
const experimentalJitContext = isFeatureEnabled(settings, 'jitContext');
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
@@ -532,7 +540,7 @@ export async function loadCliConfig(
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'plan':
if (!(settings.experimental?.plan ?? false)) {
if (!isFeatureEnabled(settings, 'plan')) {
debugLogger.warn(
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
);
@@ -759,15 +767,17 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
features: settings.features,
featureGates: argv.featureGates,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
enableExtensionReloading: isFeatureEnabled(settings, 'extensionReloading'),
enableAgents: isFeatureEnabled(settings, 'enableAgents'),
plan: isFeatureEnabled(settings, 'plan'),
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
@@ -776,9 +786,14 @@ export async function loadCliConfig(
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalJitContext: isFeatureEnabled(settings, 'jitContext'),
modelSteering: settings.experimental?.modelSteering,
toolOutputMasking: settings.experimental?.toolOutputMasking,
toolOutputMasking:
(settings.features as Record<string, unknown> | undefined)?.[
'toolOutputMasking'
] !== undefined
? { enabled: isFeatureEnabled(settings, 'toolOutputMasking') }
: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
+18 -5
View File
@@ -9,7 +9,11 @@ import * as path from 'node:path';
import { stat } from 'node:fs/promises';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type MergedSettings, SettingScope } from './settings.js';
import {
type MergedSettings,
SettingScope,
isFeatureEnabled,
} from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import {
@@ -323,7 +327,10 @@ Would you like to attempt to install via "git clone" instead?`,
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (this.requestSetting && this.settings.experimental.extensionConfig) {
if (
this.requestSetting &&
isFeatureEnabled(this.settings, 'extensionConfig')
) {
if (isUpdate) {
await maybePromptForSettings(
newExtensionConfig,
@@ -341,7 +348,10 @@ Would you like to attempt to install via "git clone" instead?`,
}
}
const missingSettings = this.settings.experimental.extensionConfig
const missingSettings = isFeatureEnabled(
this.settings,
'extensionConfig',
)
? await getMissingSettings(
newExtensionConfig,
extensionId,
@@ -677,7 +687,7 @@ Would you like to attempt to install via "git clone" instead?`,
let userSettings: Record<string, string> = {};
let workspaceSettings: Record<string, string> = {};
if (this.settings.experimental.extensionConfig) {
if (isFeatureEnabled(this.settings, 'extensionConfig')) {
userSettings = await getScopedEnvContents(
config,
extensionId,
@@ -697,7 +707,10 @@ Would you like to attempt to install via "git clone" instead?`,
config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = [];
if (config.settings && this.settings.experimental.extensionConfig) {
if (
config.settings &&
isFeatureEnabled(this.settings, 'extensionConfig')
) {
for (const setting of config.settings) {
const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined;
+7 -1
View File
@@ -206,7 +206,13 @@ describe('extension tests', () => {
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.experimental.extensionConfig = true;
if (settings.features) {
(settings.features as Record<string, unknown>)['extensionConfig'] = true;
} else {
(settings as unknown as Record<string, unknown>)['features'] = {
extensionConfig: true,
};
}
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
+48
View File
@@ -76,6 +76,8 @@ import {
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
isFeatureEnabled,
type MergedSettings,
} from './settings.js';
import {
FatalConfigError,
@@ -3119,4 +3121,50 @@ describe('LoadedSettings Isolation and Serializability', () => {
}).toThrow(/Maximum call stack size exceeded/);
});
});
describe('isFeatureEnabled', () => {
it('should return true if feature is enabled in "features"', () => {
const settings = {
features: { plan: true },
} as unknown as Settings; // Casting for simplicity
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(true);
});
it('should return false if feature is disabled in "features"', () => {
const settings = {
features: { plan: false },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
it('should fallback to "experimental" if feature is not in "features"', () => {
const settings = {
experimental: { plan: true },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(true);
});
it('should prioritize "features" over "experimental"', () => {
const settings = {
features: { plan: false },
experimental: { plan: true },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
it('should return false if neither is set', () => {
const settings = {} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
});
});
+53
View File
@@ -18,6 +18,8 @@ import {
coreEvents,
homedir,
type AdminControlsSettings,
DefaultFeatureGate,
type FeatureGate,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
@@ -43,6 +45,57 @@ export {
getSettingsSchema,
};
const featureGateCache = new WeakMap<MergedSettings, FeatureGate>();
/**
* Returns true if the feature is enabled in the given settings.
* Checks "features" first, then falls back to "experimental".
*/
export function isFeatureEnabled(
settings: MergedSettings,
featureName: string,
): boolean {
let gate = featureGateCache.get(settings);
if (!gate) {
const mutableGate = DefaultFeatureGate.deepCopy();
const overrides: Record<string, boolean> = {};
// 1. Experimental (Low Priority)
const experimental = settings.experimental as Record<string, unknown>;
if (experimental) {
for (const [key, value] of Object.entries(experimental)) {
if (typeof value === 'boolean') {
overrides[key] = value;
}
}
// Handle nested toolOutputMasking legacy structure
const toolOutputMasking = experimental['toolOutputMasking'];
if (
toolOutputMasking &&
typeof toolOutputMasking === 'object' &&
'enabled' in toolOutputMasking &&
typeof (toolOutputMasking as Record<string, unknown>)['enabled'] ===
'boolean'
) {
overrides['toolOutputMasking'] = Boolean(
(toolOutputMasking as Record<string, unknown>)['enabled'],
);
}
}
// 2. Features (High Priority)
if (settings.features) {
Object.assign(overrides, settings.features);
}
mutableGate.setFromMap(overrides);
gate = mutableGate;
featureGateCache.set(settings, gate);
}
return gate.enabled(featureName);
}
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
+126 -3
View File
@@ -1687,7 +1687,9 @@ const SETTINGS_SCHEMA = {
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Setting to enable experimental features',
ignoreInDocs: true,
description:
'DEPRECATED: Use the "features" object instead. Setting to enable experimental features',
showInDialog: false,
properties: {
toolOutputMasking: {
@@ -1708,7 +1710,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: true,
description: 'Enables tool output masking to save tokens.',
showInDialog: true,
showInDialog: false,
},
toolProtectionThreshold: {
type: 'number',
@@ -1825,7 +1827,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
showInDialog: false,
},
taskTracker: {
type: 'boolean',
@@ -2277,6 +2279,127 @@ const SETTINGS_SCHEMA = {
},
},
},
features: {
type: 'object',
label: 'Features',
category: 'Features',
requiresRestart: true,
default: {},
description: 'Feature Lifecycle Management settings.',
showInDialog: false,
properties: {
allAlpha: {
type: 'boolean',
label: 'Enable all Alpha features',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable all Alpha features by default.',
showInDialog: true,
},
allBeta: {
type: 'boolean',
label: 'Enable all Beta features',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable all Beta features by default.',
showInDialog: true,
},
toolOutputMasking: {
type: 'boolean',
label: 'Tool Output Masking',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enables tool output masking to save tokens.',
showInDialog: true,
},
enableAgents: {
type: 'boolean',
label: 'Enable Agents',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable local and remote subagents.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable extension management features.',
showInDialog: true,
},
extensionConfig: {
type: 'boolean',
label: 'Extension Configuration',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: true,
},
extensionRegistry: {
type: 'boolean',
label: 'Extension Registry Explore UI',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: true,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
category: 'Features',
requiresRestart: true,
default: false,
description:
'Enables extension loading/unloading within the CLI session.',
showInDialog: true,
},
jitContext: {
type: 'boolean',
label: 'JIT Context Loading',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable Just-In-Time (JIT) context loading.',
showInDialog: true,
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
category: 'Features',
requiresRestart: false,
default: false,
description: 'Use OSC 52 sequence for pasting.',
showInDialog: true,
},
plan: {
type: 'boolean',
label: 'Plan Mode',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
zedIntegration: {
type: 'boolean',
label: 'Zed Integration',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable Zed integration.',
showInDialog: true,
},
},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
@@ -415,7 +415,7 @@ describe('SettingsDialog', () => {
await waitFor(() => {
// Should wrap to last setting (without relying on exact bullet character)
expect(lastFrame()).toContain('Hook Notifications');
expect(lastFrame()).toContain('Zed Integration');
});
unmount();
@@ -753,6 +753,33 @@ describe('SettingsDialog', () => {
});
describe('Specific Settings Behavior', () => {
it('should render feature stage badges', async () => {
const featureSettings = createMockSettings({
'features.allAlpha': false,
'features.allBeta': true,
});
const onSelect = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider>
<SettingsDialog
settings={featureSettings}
onSelect={onSelect}
availableTerminalHeight={100}
/>
</KeypressProvider>,
);
act(() => {
stdin.write('Plan');
});
await waitFor(() => {
expect(lastFrame()).toContain('Plan Mode');
expect(lastFrame()).toContain('[ALPHA]');
});
});
it('should show correct display values for settings with different states', async () => {
const settings = createMockSettings({
user: {
@@ -33,7 +33,12 @@ import {
type SettingsValue,
TOGGLE_TYPES,
} from '../../config/settingsSchema.js';
import { debugLogger } from '@google/gemini-cli-core';
import {
coreEvents,
debugLogger,
FeatureDefinitions,
} from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import {
@@ -244,6 +249,14 @@ export function SettingsDialog({
// The inline editor needs a string but non primitive settings like Arrays and Objects exist
const editValue = getEditValue(type, rawValue);
let stage: string | undefined;
const definitionKey = key.replace(/^features\./, '');
if (key.startsWith('features.') && FeatureDefinitions[definitionKey]) {
const specs = FeatureDefinitions[definitionKey];
const latest = specs[specs.length - 1];
stage = latest.preRelease;
}
return {
key,
label: definition?.label || key,
@@ -252,8 +265,10 @@ export function SettingsDialog({
displayValue,
isGreyedOut,
scopeMessage,
rawValue,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
editValue,
stage,
};
});
}, [settingKeys, selectedScope, settings]);
@@ -26,6 +26,7 @@ import {
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { FeatureStage } from '@google/gemini-cli-core';
/**
* Represents a single item in the settings dialog.
@@ -49,6 +50,8 @@ export interface SettingsDialogItem {
rawValue?: SettingsValue;
/** Optional pre-formatted edit buffer value for complex types */
editValue?: string;
/** Feature stage (e.g. ALPHA, BETA) */
stage?: string;
}
/**
@@ -553,6 +556,20 @@ export function BaseSettingsDialog({
color={isActive ? theme.ui.focus : theme.text.primary}
>
{item.label}
{item.stage && item.stage !== FeatureStage.GA && (
<Text
color={
item.stage === FeatureStage.Deprecated
? theme.status.error
: item.stage === FeatureStage.Beta
? theme.text.accent
: theme.status.warning
}
>
{' '}
[{item.stage}]{' '}
</Text>
)}
{item.scopeMessage && (
<Text color={theme.text.secondary}>
{' '}