Merge branch 'main' into gemini-cli-startup

This commit is contained in:
Dmitry Lyalin
2026-03-12 11:45:03 -04:00
committed by GitHub
29 changed files with 742 additions and 115 deletions
+4 -4
View File
@@ -1807,7 +1807,7 @@ describe('loadCliConfig model selection', () => {
});
it('always prefers model from argv', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings({
@@ -1819,11 +1819,11 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
expect(config.getModel()).toBe('gemini-2.5-flash');
});
it('selects the model from argv if provided', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings({
@@ -1833,7 +1833,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
expect(config.getModel()).toBe('gemini-2.5-flash');
});
it('selects the default auto model if provided via auto alias', async () => {
+14
View File
@@ -31,6 +31,8 @@ import {
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
isValidModelOrAlias,
getValidModelsAndAliases,
getAdminErrorMessage,
isHeadlessMode,
Config,
@@ -696,6 +698,18 @@ export async function loadCliConfig(
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
// Validate the model if one was explicitly specified
if (specifiedModel && specifiedModel !== GEMINI_MODEL_ALIAS_AUTO) {
if (!isValidModelOrAlias(specifiedModel)) {
const validModels = getValidModelsAndAliases();
throw new FatalConfigError(
`Invalid model: "${specifiedModel}"\n\n` +
`Valid models and aliases:\n${validModels.map((m) => ` - ${m}`).join('\n')}\n\n` +
`Use /model to switch models interactively.`,
);
}
}
const resolvedModel =
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
? defaultModel
@@ -12,12 +12,13 @@ import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings, type MergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
import { themeManager } from '../ui/themes/theme-manager.js';
import {
TrustLevel,
loadTrustedFolders,
isWorkspaceTrusted,
} from './trustedFolders.js';
import { getRealPath } from '@google/gemini-cli-core';
import { getRealPath, type CustomTheme } from '@google/gemini-cli-core';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
@@ -38,6 +39,26 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
const testTheme: CustomTheme = {
type: 'custom',
name: 'MyTheme',
background: {
primary: '#282828',
diff: { added: '#2b3312', removed: '#341212' },
},
text: {
primary: '#ebdbb2',
secondary: '#a89984',
link: '#83a598',
accent: '#d3869b',
},
status: {
success: '#b8bb26',
warning: '#fabd2f',
error: '#fb4934',
},
};
describe('ExtensionManager', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
@@ -65,6 +86,7 @@ describe('ExtensionManager', () => {
});
afterEach(() => {
themeManager.clearExtensionThemes();
try {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
} catch (_e) {
@@ -484,4 +506,45 @@ describe('ExtensionManager', () => {
).rejects.toThrow(/already installed/);
});
});
describe('early theme registration', () => {
it('should register themes with ThemeManager during loadExtensions for active extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'themed-ext',
version: '1.0.0',
themes: [testTheme],
});
await extensionManager.loadExtensions();
expect(themeManager.getCustomThemeNames()).toContain(
'MyTheme (themed-ext)',
);
});
it('should not register themes for inactive extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'disabled-ext',
version: '1.0.0',
themes: [testTheme],
});
// Disable the extension by creating an enablement override
const manager = new ExtensionManager({
enabledExtensionOverrides: ['none'],
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
});
await manager.loadExtensions();
expect(themeManager.getCustomThemeNames()).not.toContain(
'MyTheme (disabled-ext)',
);
});
});
});
+8 -1
View File
@@ -576,7 +576,7 @@ Would you like to attempt to install via "git clone" instead?`,
protected override async startExtension(extension: GeminiCLIExtension) {
await super.startExtension(extension);
if (extension.themes) {
if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) {
themeManager.registerExtensionThemes(extension.name, extension.themes);
}
}
@@ -636,6 +636,13 @@ Would you like to attempt to install via "git clone" instead?`,
this.loadedExtensions = builtExtensions;
// Register extension themes early so they're available at startup.
for (const ext of this.loadedExtensions) {
if (ext.isActive && ext.themes) {
themeManager.registerExtensionThemes(ext.name, ext.themes);
}
}
await Promise.all(
this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)),
);
@@ -37,6 +37,7 @@ describe('upgradeCommand', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
getUserTierName: vi.fn().mockReturnValue(undefined),
},
},
} as unknown as CommandContext);
@@ -115,4 +116,23 @@ describe('upgradeCommand', () => {
});
expect(openBrowserSecurely).not.toHaveBeenCalled();
});
it('should return info message for ultra tiers', async () => {
vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue(
'Advanced Ultra',
);
if (!upgradeCommand.action) {
throw new Error('The upgrade command must have an action.');
}
const result = await upgradeCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'You are already on the highest tier: Advanced Ultra.',
});
expect(openBrowserSecurely).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,7 @@ import {
shouldLaunchBrowser,
UPGRADE_URL_PAGE,
} from '@google/gemini-cli-core';
import { isUltraTier } from '../../utils/tierUtils.js';
import { CommandKind, type SlashCommand } from './types.js';
/**
@@ -35,6 +36,15 @@ export const upgradeCommand: SlashCommand = {
};
}
const tierName = context.services.config?.getUserTierName();
if (isUltraTier(tierName)) {
return {
type: 'message',
messageType: 'info',
content: `You are already on the highest tier: ${tierName}.`,
};
}
if (!shouldLaunchBrowser()) {
return {
type: 'message',
@@ -87,6 +87,7 @@ export const DialogManager = ({
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice}
/>
);
@@ -202,6 +202,40 @@ describe('ProQuotaDialog', () => {
);
unmount();
});
it('should NOT render upgrade option for LOGIN_WITH_GOOGLE if tier is Ultra', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="free tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
authType={AuthType.LOGIN_WITH_GOOGLE}
tierName="Gemini Advanced Ultra"
onChoice={mockOnChoice}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
describe('when it is a capacity error', () => {
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { AuthType } from '@google/gemini-cli-core';
import { isUltraTier } from '../../utils/tierUtils.js';
interface ProQuotaDialogProps {
failedModel: string;
@@ -17,6 +18,7 @@ interface ProQuotaDialogProps {
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
authType?: AuthType;
tierName?: string;
onChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
@@ -29,6 +31,7 @@ export function ProQuotaDialog({
isTerminalQuotaError,
isModelNotFoundError,
authType,
tierName,
onChoice,
}: ProQuotaDialogProps): React.JSX.Element {
let items;
@@ -47,6 +50,8 @@ export function ProQuotaDialog({
},
];
} else if (isModelNotFoundError || isTerminalQuotaError) {
const isUltra = isUltraTier(tierName);
// free users and out of quota users on G1 pro and Cloud Console gets an option to upgrade
items = [
{
@@ -54,7 +59,7 @@ export function ProQuotaDialog({
value: 'retry_always' as const,
key: 'retry_always',
},
...(authType === AuthType.LOGIN_WITH_GOOGLE
...(authType === AuthType.LOGIN_WITH_GOOGLE && !isUltra
? [
{
label: 'Upgrade for higher limits',
@@ -182,4 +182,23 @@ describe('<UserIdentity />', () => {
expect(output).toContain('/upgrade');
unmount();
});
it('should not render /upgrade indicator for ultra tiers', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Advanced Ultra');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Plan: Advanced Ultra');
expect(output).not.toContain('/upgrade');
unmount();
});
});
@@ -13,6 +13,7 @@ import {
UserAccountManager,
AuthType,
} from '@google/gemini-cli-core';
import { isUltraTier } from '../../utils/tierUtils.js';
interface UserIdentityProps {
config: Config;
@@ -33,6 +34,8 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
[config, authType],
);
const isUltra = useMemo(() => isUltraTier(tierName), [tierName]);
if (!authType) {
return null;
}
@@ -60,7 +63,7 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
<Text color={theme.text.primary} wrap="truncate-end">
<Text bold>Plan:</Text> {tierName}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
{!isUltra && <Text color={theme.text.secondary}> /upgrade</Text>}
</Box>
)}
</Box>
+11 -5
View File
@@ -174,11 +174,6 @@ class ThemeManager {
return;
}
debugLogger.log(
`Registering extension themes for "${extensionName}":`,
customThemes,
);
for (const customThemeConfig of customThemes) {
const namespacedName = `${customThemeConfig.name} (${extensionName})`;
@@ -240,6 +235,17 @@ class ThemeManager {
}
}
/**
* Checks if themes for a given extension are already registered.
* @param extensionName The name of the extension.
* @returns True if any themes from the extension are registered.
*/
hasExtensionThemes(extensionName: string): boolean {
return Array.from(this.extensionThemes.keys()).some((name) =>
name.endsWith(`(${extensionName})`),
);
}
/**
* Clears all registered extension themes.
* This is primarily for testing purposes to reset state between tests.
+28
View File
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { isUltraTier } from './tierUtils.js';
describe('tierUtils', () => {
describe('isUltraTier', () => {
it('should return true if tier name contains "ultra" (case-insensitive)', () => {
expect(isUltraTier('Advanced Ultra')).toBe(true);
expect(isUltraTier('gemini ultra')).toBe(true);
expect(isUltraTier('ULTRA')).toBe(true);
});
it('should return false if tier name does not contain "ultra"', () => {
expect(isUltraTier('Free')).toBe(false);
expect(isUltraTier('Pro')).toBe(false);
expect(isUltraTier('Standard')).toBe(false);
});
it('should return false if tier name is undefined', () => {
expect(isUltraTier(undefined)).toBe(false);
});
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Checks if the given tier name corresponds to an "Ultra" tier.
*
* @param tierName The name of the user's tier.
* @returns True if the tier is an "Ultra" tier, false otherwise.
*/
export function isUltraTier(tierName?: string): boolean {
return !!tierName?.toLowerCase().includes('ultra');
}
+5 -3
View File
@@ -107,9 +107,11 @@ const localAgentSchema = z
display_name: z.string().optional(),
tools: z
.array(
z.string().refine((val) => isValidToolName(val), {
message: 'Invalid tool name',
}),
z
.string()
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
message: 'Invalid tool name',
}),
)
.optional(),
model: z.string().optional(),
+48 -18
View File
@@ -17,7 +17,13 @@ import {
type Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { type AnyDeclarativeTool } from '../tools/tools.js';
import {
DiscoveredMCPTool,
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from '../tools/mcp-tool.js';
import { CompressionStatus } from '../core/turn.js';
import { type ToolCallRequestInfo } from '../scheduler/types.js';
import { type Message } from '../confirmation-bus/types.js';
@@ -146,28 +152,55 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
context.config.getAgentRegistry().getAllAgentNames(),
);
const registerToolByName = (toolName: string) => {
const registerToolInstance = (tool: AnyDeclarativeTool) => {
// Check if the tool is a subagent to prevent recursion.
// We do not allow agents to call other agents.
if (allAgentNames.has(toolName)) {
if (allAgentNames.has(tool.name)) {
debugLogger.warn(
`[LocalAgentExecutor] Skipping subagent tool '${toolName}' for agent '${definition.name}' to prevent recursion.`,
`[LocalAgentExecutor] Skipping subagent tool '${tool.name}' for agent '${definition.name}' to prevent recursion.`,
);
return;
}
agentToolRegistry.registerTool(tool);
};
const registerToolByName = (toolName: string) => {
// Handle global wildcard
if (toolName === '*') {
for (const tool of parentToolRegistry.getAllTools()) {
registerToolInstance(tool);
}
return;
}
// Handle MCP wildcards
if (isMcpToolName(toolName)) {
if (toolName === `${MCP_TOOL_PREFIX}*`) {
for (const tool of parentToolRegistry.getAllTools()) {
if (tool instanceof DiscoveredMCPTool) {
registerToolInstance(tool);
}
}
return;
}
const parsed = parseMcpToolName(toolName);
if (parsed.serverName && parsed.toolName === '*') {
for (const tool of parentToolRegistry.getToolsByServer(
parsed.serverName,
)) {
registerToolInstance(tool);
}
return;
}
}
// If the tool is referenced by name, retrieve it from the parent
// registry and register it with the agent's isolated registry.
const tool = parentToolRegistry.getTool(toolName);
if (tool) {
if (tool instanceof DiscoveredMCPTool) {
// Subagents MUST use fully qualified names for MCP tools to ensure
// unambiguous tool calls and to comply with policy requirements.
// We automatically "upgrade" any MCP tool to its qualified version.
agentToolRegistry.registerTool(tool.asFullyQualifiedTool());
} else {
agentToolRegistry.registerTool(tool);
}
registerToolInstance(tool);
}
};
@@ -1175,10 +1208,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const { toolConfig, outputConfig } = this.definition;
if (toolConfig) {
const toolNamesToLoad: string[] = [];
for (const toolRef of toolConfig.tools) {
if (typeof toolRef === 'string') {
toolNamesToLoad.push(toolRef);
// The names were already expanded and loaded into the registry during creation.
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
// Tool instance with an explicit schema property.
toolsList.push(toolRef.schema);
@@ -1187,10 +1219,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
toolsList.push(toolRef);
}
}
// Add schemas from tools that were registered by name.
toolsList.push(
...this.toolRegistry.getFunctionDeclarationsFiltered(toolNamesToLoad),
);
// Add schemas from tools that were explicitly registered by name or wildcard.
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
}
// Always inject complete_task.
+64
View File
@@ -22,6 +22,7 @@ import {
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
@@ -30,6 +31,10 @@ import {
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
isValidModelOrAlias,
getValidModelsAndAliases,
VALID_GEMINI_MODELS,
VALID_ALIASES,
} from './models.js';
describe('isPreviewModel', () => {
@@ -389,3 +394,62 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('isValidModelOrAlias', () => {
it('should return true for valid model names', () => {
expect(isValidModelOrAlias(DEFAULT_GEMINI_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isValidModelOrAlias(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
expect(isValidModelOrAlias(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_3_1_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe(
true,
);
});
it('should return true for valid aliases', () => {
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_AUTO)).toBe(true);
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_PRO)).toBe(true);
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_FLASH)).toBe(true);
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_FLASH_LITE)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true);
expect(isValidModelOrAlias(DEFAULT_GEMINI_MODEL_AUTO)).toBe(true);
});
it('should return true for custom (non-gemini) models', () => {
expect(isValidModelOrAlias('gpt-4')).toBe(true);
expect(isValidModelOrAlias('claude-3')).toBe(true);
expect(isValidModelOrAlias('my-custom-model')).toBe(true);
});
it('should return false for invalid gemini model names', () => {
expect(isValidModelOrAlias('gemini-4-pro')).toBe(false);
expect(isValidModelOrAlias('gemini-99-flash')).toBe(false);
expect(isValidModelOrAlias('gemini-invalid')).toBe(false);
});
});
describe('getValidModelsAndAliases', () => {
it('should return a sorted array', () => {
const result = getValidModelsAndAliases();
const sorted = [...result].sort();
expect(result).toEqual(sorted);
});
it('should include all valid models and aliases', () => {
const result = getValidModelsAndAliases();
for (const model of VALID_GEMINI_MODELS) {
expect(result).toContain(model);
}
for (const alias of VALID_ALIASES) {
expect(result).toContain(alias);
}
});
it('should not contain duplicates', () => {
const result = getValidModelsAndAliases();
const unique = [...new Set(result)];
expect(result).toEqual(unique);
});
});
+43
View File
@@ -32,6 +32,15 @@ export const GEMINI_MODEL_ALIAS_PRO = 'pro';
export const GEMINI_MODEL_ALIAS_FLASH = 'flash';
export const GEMINI_MODEL_ALIAS_FLASH_LITE = 'flash-lite';
export const VALID_ALIASES = new Set([
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
]);
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops.
@@ -283,3 +292,37 @@ export function isActiveModel(
);
}
}
/**
* Checks if the model name is valid (either a valid model or a valid alias).
*
* @param model The model name to check.
* @returns True if the model is valid.
*/
export function isValidModelOrAlias(model: string): boolean {
// Check if it's a valid alias
if (VALID_ALIASES.has(model)) {
return true;
}
// Check if it's a valid model name
if (VALID_GEMINI_MODELS.has(model)) {
return true;
}
// Allow custom models (non-gemini models)
if (!model.startsWith('gemini-')) {
return true;
}
return false;
}
/**
* Gets a list of all valid model names and aliases for error messages.
*
* @returns Array of valid model names and aliases.
*/
export function getValidModelsAndAliases(): string[] {
return [...new Set([...VALID_ALIASES, ...VALID_GEMINI_MODELS])].sort();
}
@@ -51,6 +51,12 @@ import { InstallationManager } from '../../utils/installationManager.js';
import si, { type Systeminformation } from 'systeminformation';
import * as os from 'node:os';
import {
CreditsUsedEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
} from '../billingEvents.js';
interface CustomMatchers<R = unknown> {
toHaveMetadataValue: ([key, value]: [EventMetadataKey, string]) => R;
@@ -1551,4 +1557,99 @@ describe('ClearcutLogger', () => {
]);
});
});
describe('logCreditsUsedEvent', () => {
it('logs an event with model, consumed, and remaining credits', () => {
const { logger } = setup();
const event = new CreditsUsedEvent('gemini-3-pro-preview', 10, 490);
logger?.logCreditsUsedEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.CREDITS_USED);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_CONSUMED,
'10',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_REMAINING,
'490',
]);
});
});
describe('logOverageOptionSelectedEvent', () => {
it('logs an event with model, selected option, and credit balance', () => {
const { logger } = setup();
const event = new OverageOptionSelectedEvent(
'gemini-3-pro-preview',
'use_credits',
350,
);
logger?.logOverageOptionSelectedEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.OVERAGE_OPTION_SELECTED);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_SELECTED_OPTION,
'"use_credits"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_CREDIT_BALANCE,
'350',
]);
});
});
describe('logEmptyWalletMenuShownEvent', () => {
it('logs an event with the model', () => {
const { logger } = setup();
const event = new EmptyWalletMenuShownEvent('gemini-3-pro-preview');
logger?.logEmptyWalletMenuShownEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.EMPTY_WALLET_MENU_SHOWN);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
});
});
describe('logCreditPurchaseClickEvent', () => {
it('logs an event with model and source', () => {
const { logger } = setup();
const event = new CreditPurchaseClickEvent(
'empty_wallet_menu',
'gemini-3-pro-preview',
);
logger?.logCreditPurchaseClickEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.CREDIT_PURCHASE_CLICK);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_PURCHASE_SOURCE,
'"empty_wallet_menu"',
]);
});
});
});
@@ -52,6 +52,12 @@ import type {
TokenStorageInitializationEvent,
StartupStatsEvent,
} from '../types.js';
import type {
CreditsUsedEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
} from '../billingEvents.js';
import { EventMetadataKey } from './event-metadata-key.js';
import type { Config } from '../../config/config.js';
import { InstallationManager } from '../../utils/installationManager.js';
@@ -121,6 +127,10 @@ export enum EventNames {
CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
CONSECA_VERDICT = 'conseca_verdict',
STARTUP_STATS = 'startup_stats',
CREDITS_USED = 'credits_used',
OVERAGE_OPTION_SELECTED = 'overage_option_selected',
EMPTY_WALLET_MENU_SHOWN = 'empty_wallet_menu_shown',
CREDIT_PURCHASE_CLICK = 'credit_purchase_click',
}
export interface LogResponse {
@@ -1806,6 +1816,84 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
// ==========================================================================
// Billing / AI Credits Events
// ==========================================================================
logCreditsUsedEvent(event: CreditsUsedEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_CONSUMED,
value: JSON.stringify(event.credits_consumed),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_REMAINING,
value: JSON.stringify(event.credits_remaining),
},
];
this.enqueueLogEvent(this.createLogEvent(EventNames.CREDITS_USED, data));
this.flushIfNeeded();
}
logOverageOptionSelectedEvent(event: OverageOptionSelectedEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_SELECTED_OPTION,
value: JSON.stringify(event.selected_option),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_CREDIT_BALANCE,
value: JSON.stringify(event.credit_balance),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.OVERAGE_OPTION_SELECTED, data),
);
this.flushIfNeeded();
}
logEmptyWalletMenuShownEvent(event: EmptyWalletMenuShownEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.EMPTY_WALLET_MENU_SHOWN, data),
);
this.flushIfNeeded();
}
logCreditPurchaseClickEvent(event: CreditPurchaseClickEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_PURCHASE_SOURCE,
value: JSON.stringify(event.source),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.CREDIT_PURCHASE_CLICK, data),
);
this.flushIfNeeded();
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 180
// Next ID: 191
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -687,4 +687,26 @@ export enum EventMetadataKey {
// Logs the error type for a network retry.
GEMINI_CLI_NETWORK_RETRY_ERROR_TYPE = 182,
// ==========================================================================
// Billing / AI Credits Event Keys
// ==========================================================================
// Logs the model associated with a billing event.
GEMINI_CLI_BILLING_MODEL = 185,
// Logs the number of AI credits consumed in a request.
GEMINI_CLI_BILLING_CREDITS_CONSUMED = 186,
// Logs the remaining AI credits after a request.
GEMINI_CLI_BILLING_CREDITS_REMAINING = 187,
// Logs the overage option selected by the user (e.g. use_credits, use_fallback, manage, stop).
GEMINI_CLI_BILLING_SELECTED_OPTION = 188,
// Logs the user's credit balance when the overage menu was shown.
GEMINI_CLI_BILLING_CREDIT_BALANCE = 189,
// Logs the source of a credit purchase click (e.g. overage_menu, empty_wallet_menu, manage).
GEMINI_CLI_BILLING_PURCHASE_SOURCE = 190,
}
+19
View File
@@ -85,6 +85,12 @@ import { uiTelemetryService, type UiEvent } from './uiTelemetry.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { BillingTelemetryEvent } from './billingEvents.js';
import {
CreditsUsedEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
} from './billingEvents.js';
export function logCliConfiguration(
config: Config,
@@ -877,4 +883,17 @@ export function logBillingEvent(
};
logger.emit(logRecord);
});
const cc = ClearcutLogger.getInstance(config);
if (cc) {
if (event instanceof CreditsUsedEvent) {
cc.logCreditsUsedEvent(event);
} else if (event instanceof OverageOptionSelectedEvent) {
cc.logOverageOptionSelectedEvent(event);
} else if (event instanceof EmptyWalletMenuShownEvent) {
cc.logEmptyWalletMenuShownEvent(event);
} else if (event instanceof CreditPurchaseClickEvent) {
cc.logCreditPurchaseClickEvent(event);
}
}
}
@@ -339,6 +339,26 @@ Ask the user for specific feedback on how to improve the plan.`,
});
});
describe('execute when shouldConfirmExecute is never called', () => {
it('should approve with DEFAULT mode when approvalPayload is null (policy ALLOW skips confirmation)', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
// Simulate the scheduler's policy ALLOW path: execute() is called
// directly without ever calling shouldConfirmExecute(), leaving
// approvalPayload null.
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.join(mockPlansDir, 'test.md');
expect(result.llmContent).toContain('Plan approved');
expect(result.returnDisplay).toContain('Plan approved');
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath);
});
});
describe('getApprovalModeDescription (internal)', () => {
it('should handle all valid approval modes', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
+10 -2
View File
@@ -203,8 +203,16 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
};
}
const payload = this.approvalPayload;
if (payload?.approved) {
// When a user policy grants `allow` for exit_plan_mode, the scheduler
// skips the confirmation phase entirely and shouldConfirmExecute is never
// called, leaving approvalPayload null. Treat that as an approval with
// the default mode — consistent with the ALLOW branch inside
// shouldConfirmExecute.
const payload = this.approvalPayload ?? {
approved: true,
approvalMode: ApprovalMode.DEFAULT,
};
if (payload.approved) {
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
if (newMode === ApprovalMode.PLAN || newMode === ApprovalMode.YOLO) {
+1 -19
View File
@@ -58,6 +58,7 @@ export function parseMcpToolName(name: string): {
// Remove the prefix
const withoutPrefix = name.slice(MCP_TOOL_PREFIX.length);
// The first segment is the server name, the rest is the tool name
// Must be strictly `server_tool` where neither are empty
const match = withoutPrefix.match(/^([^_]+)_(.+)$/);
if (match) {
return {
@@ -390,25 +391,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
);
}
asFullyQualifiedTool(): DiscoveredMCPTool {
return new DiscoveredMCPTool(
this.mcpTool,
this.serverName,
this.serverToolName,
this.description,
this.parameterSchema,
this.messageBus,
this.trust,
this.isReadOnly,
this.getFullyQualifiedName(),
this.cliConfig,
this.extensionName,
this.extensionId,
this._toolAnnotations,
);
}
protected createInvocation(
params: ToolParams,
messageBus: MessageBus,
+22 -18
View File
@@ -25,7 +25,8 @@ vi.mock('./tool-names.js', async (importOriginal) => {
...actual,
TOOL_LEGACY_ALIASES: mockedAliases,
isValidToolName: vi.fn().mockImplementation((name: string, options) => {
if (mockedAliases[name]) return true;
if (Object.prototype.hasOwnProperty.call(mockedAliases, name))
return true;
return actual.isValidToolName(name, options);
}),
getToolAliases: vi.fn().mockImplementation((name: string) => {
@@ -55,11 +56,9 @@ describe('tool-names', () => {
expect(isValidToolName(`${DISCOVERED_TOOL_PREFIX}my_tool`)).toBe(true);
});
it('should validate MCP tool names (server__tool)', () => {
expect(isValidToolName('server__tool')).toBe(true);
expect(isValidToolName('my-server__my-tool')).toBe(true);
expect(isValidToolName('my.server__my:tool')).toBe(true);
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
it('should validate modern MCP FQNs (mcp_server_tool)', () => {
expect(isValidToolName('mcp_server_tool')).toBe(true);
expect(isValidToolName('mcp_my-server_my-tool')).toBe(true);
});
it('should validate legacy tool aliases', async () => {
@@ -69,28 +68,33 @@ describe('tool-names', () => {
}
});
it('should reject invalid tool names', () => {
expect(isValidToolName('')).toBe(false);
expect(isValidToolName('invalid-name')).toBe(false);
expect(isValidToolName('server__')).toBe(false);
expect(isValidToolName('__tool')).toBe(false);
expect(isValidToolName('server__tool__extra')).toBe(false);
it('should return false for invalid tool names', () => {
expect(isValidToolName('invalid-tool-name')).toBe(false);
expect(isValidToolName('mcp_server')).toBe(false);
expect(isValidToolName('mcp__tool')).toBe(false);
expect(isValidToolName('mcp_invalid server_tool')).toBe(false);
expect(isValidToolName('mcp_server_invalid tool')).toBe(false);
expect(isValidToolName('mcp_server_')).toBe(false);
});
it('should handle wildcards when allowed', () => {
// Default: not allowed
expect(isValidToolName('*')).toBe(false);
expect(isValidToolName('server__*')).toBe(false);
expect(isValidToolName('mcp_*')).toBe(false);
expect(isValidToolName('mcp_server_*')).toBe(false);
// Explicitly allowed
expect(isValidToolName('*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('server__*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('mcp_*', { allowWildcards: true })).toBe(true);
expect(isValidToolName('mcp_server_*', { allowWildcards: true })).toBe(
true,
);
// Invalid wildcards
expect(isValidToolName('__*', { allowWildcards: true })).toBe(false);
expect(isValidToolName('server__tool*', { allowWildcards: true })).toBe(
false,
);
expect(isValidToolName('mcp__*', { allowWildcards: true })).toBe(false);
expect(
isValidToolName('mcp_server_tool*', { allowWildcards: true }),
).toBe(false);
});
});
+38 -13
View File
@@ -221,6 +221,12 @@ export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';
/**
* List of all built-in tool names.
*/
import {
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from './mcp-tool.js';
export const ALL_BUILTIN_TOOL_NAMES = [
GLOB_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
@@ -290,25 +296,44 @@ export function isValidToolName(
return true;
}
// MCP tools (format: server__tool)
if (name.includes('__')) {
const parts = name.split('__');
if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
// Handle standard MCP FQNs (mcp_server_tool or wildcards mcp_*, mcp_server_*)
if (isMcpToolName(name)) {
// Global wildcard: mcp_*
if (name === `${MCP_TOOL_PREFIX}*` && options.allowWildcards) {
return true;
}
// Explicitly reject names with empty server component (e.g. mcp__tool)
if (name.startsWith(`${MCP_TOOL_PREFIX}_`)) {
return false;
}
const server = parts[0];
const tool = parts[1];
const parsed = parseMcpToolName(name);
// Ensure that both components are populated. parseMcpToolName splits at the second _,
// so `mcp__tool` has serverName="", toolName="tool"
if (parsed.serverName && parsed.toolName) {
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
if (tool === '*') {
return !!options.allowWildcards;
if (!slugRegex.test(parsed.serverName)) {
return false;
}
if (parsed.toolName === '*') {
return options.allowWildcards === true;
}
// A tool name consisting only of underscores is invalid.
if (/^_*$/.test(parsed.toolName)) {
return false;
}
return slugRegex.test(parsed.toolName);
}
// Basic slug validation for server and tool names.
// We allow dots (.) and colons (:) as they are valid in function names and
// used for truncation markers.
const slugRegex = /^[a-z0-9_.:-]+$/i;
return slugRegex.test(server) && slugRegex.test(tool);
return false;
}
return false;
@@ -310,13 +310,13 @@ describe('ToolRegistry', () => {
excludedTools: ['tool-a'],
},
{
name: 'should match simple MCP tool names, when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
name: 'should match simple MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
name: 'should match qualified MCP tool names when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
name: 'should match qualified MCP tool names',
tools: [mcpTool],
excludedTools: [mcpTool.name],
},
{
@@ -414,9 +414,9 @@ describe('ToolRegistry', () => {
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'desc');
// Register same MCP tool twice (one as alias, one as qualified)
// Register same MCP tool twice
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
const toolNames = toolRegistry.getAllToolNames();
expect(toolNames).toEqual([`mcp_${serverName}_${toolName}`]);
@@ -698,9 +698,8 @@ describe('ToolRegistry', () => {
const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register both alias and qualified
toolRegistry.registerTool(mcpTool);
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
toolRegistry.registerTool(mcpTool);
const declarations = toolRegistry.getFunctionDeclarations();
expect(declarations).toHaveLength(1);
+15 -20
View File
@@ -222,14 +222,10 @@ export class ToolRegistry {
*/
registerTool(tool: AnyDeclarativeTool): void {
if (this.allKnownTools.has(tool.name)) {
if (tool instanceof DiscoveredMCPTool) {
tool = tool.asFullyQualifiedTool();
} else {
// Decide on behavior: throw error, log warning, or allow overwrite
debugLogger.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
// Decide on behavior: throw error, log warning, or allow overwrite
debugLogger.warn(
`Tool with name "${tool.name}" is already registered. Overwriting.`,
);
}
this.allKnownTools.set(tool.name, tool);
}
@@ -594,7 +590,17 @@ export class ToolRegistry {
for (const name of toolNames) {
const tool = this.getTool(name);
if (tool) {
declarations.push(tool.getSchema(modelId));
let schema = tool.getSchema(modelId);
// Ensure the schema name matches the qualified name for MCP tools
if (tool instanceof DiscoveredMCPTool) {
schema = {
...schema,
name: tool.getFullyQualifiedName(),
};
}
declarations.push(schema);
}
}
return declarations;
@@ -670,17 +676,6 @@ export class ToolRegistry {
}
}
if (!tool && name.includes('__')) {
for (const t of this.allKnownTools.values()) {
if (t instanceof DiscoveredMCPTool) {
if (t.getFullyQualifiedName() === name) {
tool = t;
break;
}
}
}
}
if (tool && this.isActiveTool(tool)) {
return tool;
}