Compare commits

..

5 Commits

71 changed files with 371 additions and 1880 deletions
-71
View File
@@ -602,12 +602,6 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3.1-flash-lite"
}
},
"gemini-3.5-flash": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.5-flash"
}
},
"gemma-4-31b-it": {
"extends": "chat-base-3",
"modelConfig": {
@@ -632,12 +626,6 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.5-flash-base": {
"extends": "base",
"modelConfig": {
"model": "gemini-3.5-flash"
}
},
"classifier": {
"extends": "base",
"modelConfig": {
@@ -880,16 +868,6 @@ their corresponding top-level category object in your `settings.json` file.
"multimodalToolUse": true
}
},
"gemini-3.5-flash": {
"tier": "flash",
"family": "gemini-3",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": true
}
},
"gemini-2.5-pro": {
"tier": "pro",
"family": "gemini-2.5",
@@ -1042,46 +1020,9 @@ their corresponding top-level category object in your `settings.json` file.
"contexts": [
{
"condition": {
"hasAccessToPreview": false,
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
},
{
"condition": {
"hasAccessToPreview": false,
"useGemini3_5Flash": false
},
"target": "gemini-2.5-flash"
}
]
},
"gemini-3.5-flash": {
"default": "gemini-3.5-flash",
"contexts": [
{
"condition": {
"useGemini3_5Flash": false,
"hasAccessToPreview": false
},
"target": "gemini-2.5-flash"
},
{
"condition": {
"useGemini3_5Flash": false
},
"target": "gemini-3-flash-preview"
}
]
},
"gemini-2.5-flash": {
"default": "gemini-2.5-flash",
"contexts": [
{
"condition": {
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
}
]
},
@@ -1163,12 +1104,6 @@ their corresponding top-level category object in your `settings.json` file.
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
},
{
"condition": {
"hasAccessToPreview": false
@@ -1222,12 +1157,6 @@ their corresponding top-level category object in your `settings.json` file.
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
},
{
"condition": {
"hasAccessToPreview": false
+4 -24
View File
@@ -76,30 +76,10 @@ export class LLMJudge {
for (const res of rawResults) {
// Remove any punctuation the model might have appended
const cleanRes = res.replace(/[^A-Z ]/g, '');
if (
cleanRes.includes('THE ANSWER IS YES') ||
cleanRes.includes('ANSWER IS YES') ||
cleanRes.endsWith('YES')
) {
yes++;
} else if (
cleanRes.includes('THE ANSWER IS NO') ||
cleanRes.includes('ANSWER IS NO') ||
cleanRes.endsWith('NO')
) {
no++;
} else if (cleanRes.trim() === 'YES') {
yes++;
} else if (cleanRes.trim() === 'NO') {
no++;
} else {
// Fallback: look for YES or NO as standalone words or at the end
const words = cleanRes.split(/\s+/);
if (words.includes('YES')) yes++;
else if (words.includes('NO')) no++;
else other++;
}
const cleanRes = res.replace(/[^A-Z]/g, '');
if (cleanRes.startsWith('YES')) yes++;
else if (cleanRes.startsWith('NO')) no++;
else other++;
}
// Pass if YES > NO and YES > OTHER (plurality)
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"workspaces": [
"packages/*"
],
@@ -18117,7 +18117,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.19.0",
@@ -18246,7 +18246,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18394,7 +18394,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18674,7 +18674,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18689,7 +18689,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18720,7 +18720,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18752,7 +18752,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-nightly.20260521.g854f811be"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+9 -16
View File
@@ -17,24 +17,17 @@ import {
// --- Global Entry Point ---
// Suppress known race condition error in node-pty on Windows and Linux
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
if (error instanceof Error) {
const message = error.message || '';
const isPtyResizeError =
message === 'Cannot resize a pty that has already exited';
const isEbadfError =
message.includes('EBADF') ||
(error as { code?: string }).code === 'EBADF';
const isFromNodePty =
error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
// This error happens with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
}
if (
process.platform === 'win32' &&
error instanceof Error &&
error.message === 'Cannot resize a pty that has already exited'
) {
// This error happens on Windows with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
}
// For other errors, we rely on the default behavior, but since we attached a listener,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-nightly.20260521.g854f811be"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
-3
View File
@@ -265,7 +265,6 @@ export function buildAvailableModels(
const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
@@ -277,7 +276,6 @@ export function buildAvailableModels(
) {
const options = config.getModelConfigService().getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
});
@@ -296,7 +294,6 @@ export function buildAvailableModels(
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
),
},
];
-254
View File
@@ -475,258 +475,4 @@ describe('mcp list command', () => {
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
excluded: ['blocked-server'],
},
},
originalSettings: {
mcp: {
excluded: ['blocked-server'],
},
},
},
workspace: {
path: '/workspace/settings.json',
settings: {
mcp: {
excluded: [],
},
},
originalSettings: {
mcp: {
excluded: [],
},
},
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
isTrusted: true,
merged: {
mcp: {
excluded: [], // workspace has overridden user settings!
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'blocked-server: /test/server (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should block servers case-insensitively when excluded', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
excluded: ['BLOCKED-server'],
},
},
originalSettings: {
mcp: {
excluded: ['BLOCKED-server'],
},
},
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
isTrusted: true,
merged: {
mcpServers: {
'blocked-server': { command: '/test/server' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'blocked-server: /test/server (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should restrict allowed servers to the intersection of all defined allowlists', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
allowed: ['allowed-server-1', 'allowed-server-2'],
},
},
originalSettings: {
mcp: {
allowed: ['allowed-server-1', 'allowed-server-2'],
},
},
},
workspace: {
path: '/workspace/settings.json',
settings: {
mcp: {
allowed: ['allowed-server-1', 'malicious-server'],
},
},
originalSettings: {
mcp: {
allowed: ['allowed-server-1', 'malicious-server'],
},
},
},
mcpServers: {
'allowed-server-1': { command: '/allowed/1' },
'allowed-server-2': { command: '/allowed/2' },
'malicious-server': { command: '/malicious' },
},
isTrusted: true,
merged: {
mcp: {
allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings!
},
mcpServers: {
'allowed-server-1': { command: '/allowed/1' },
'allowed-server-2': { command: '/allowed/2' },
'malicious-server': { command: '/malicious' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers();
// allowed-server-1 is in the intersection, so it should connect
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'allowed-server-1: /allowed/1 (stdio) - Connected',
),
);
// allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'allowed-server-2: /allowed/2 (stdio) - Blocked',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'malicious-server: /malicious (stdio) - Blocked',
),
);
expect(mockedCreateTransport).toHaveBeenCalledTimes(1);
expect(mockedCreateTransport).toHaveBeenCalledWith(
'allowed-server-1',
expect.any(Object),
false,
expect.any(Object),
);
});
it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
allowed: ['user-allowed-server'],
},
},
originalSettings: {
mcp: {
allowed: ['user-allowed-server'],
},
},
},
workspace: {
path: '/workspace/settings.json',
settings: {
mcp: {
allowed: ['workspace-allowed-server'],
},
},
originalSettings: {
mcp: {
allowed: ['workspace-allowed-server'],
},
},
},
mcpServers: {
'user-allowed-server': { command: '/allowed/user' },
'workspace-allowed-server': { command: '/allowed/workspace' },
},
isTrusted: true,
merged: {
mcp: {
allowed: ['workspace-allowed-server'], // workspace override
},
mcpServers: {
'user-allowed-server': { command: '/allowed/user' },
'workspace-allowed-server': { command: '/allowed/workspace' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
// Since the intersection is empty ([]), both servers should be Blocked!
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'user-allowed-server: /allowed/user (stdio) - Blocked',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'workspace-allowed-server: /allowed/workspace (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should block all servers if allowlist is configured as empty array []', async () => {
const mockSettings = createMockSettings({
mcp: {
allowed: [], // empty allowlist configured!
},
mcpServers: {
'test-server': { command: '/test/server' },
},
isTrusted: true,
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('test-server: /test/server (stdio) - Blocked'),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
});
+2 -12
View File
@@ -159,16 +159,12 @@ async function getServerStatus(
server: MCPServerConfig,
isTrusted: boolean,
activeSettings: MergedSettings,
consolidatedExcluded: string[],
consolidatedAllowed: string[] | undefined,
): Promise<MCPServerStatus> {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const loadResult = await canLoadServer(serverName, {
adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true,
allowedList: consolidatedAllowed,
excludedList:
consolidatedExcluded.length > 0 ? consolidatedExcluded : undefined,
allowedList: activeSettings.mcp?.allowed,
excludedList: activeSettings.mcp?.excluded,
enablement: mcpEnablementManager.getEnablementCallbacks(),
});
@@ -231,10 +227,6 @@ export async function listMcpServers(
);
}
const consolidatedExcluded =
loadedSettings.getConsolidatedExcludedMcpServers();
const consolidatedAllowed = loadedSettings.getConsolidatedAllowedMcpServers();
debugLogger.log('Configured MCP servers:\n');
for (const serverName of serverNames) {
@@ -245,8 +237,6 @@ export async function listMcpServers(
server,
loadedSettings.isTrusted,
activeSettings,
consolidatedExcluded,
consolidatedAllowed,
);
let statusIndicator = '';
+3 -14
View File
@@ -576,7 +576,6 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
loadedSettings?: LoadedSettings;
}
export async function loadCliConfig(
@@ -585,12 +584,7 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const {
cwd = process.cwd(),
projectHooks,
skipExtensions = false,
loadedSettings,
} = options;
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -991,17 +985,12 @@ export async function loadCliConfig(
agents: settings.agents,
adminSkillsEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ??
(loadedSettings
? loadedSettings.getConsolidatedAllowedMcpServers()
: settings.mcp?.allowed))
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
: undefined,
blockedMcpServers: mcpEnabled
? argv.allowedMcpServerNames
? undefined
: loadedSettings
? loadedSettings.getConsolidatedExcludedMcpServers()
: settings.mcp?.excluded
: settings.mcp?.excluded
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
@@ -119,7 +119,7 @@ export async function canLoadServer(
}
// 2. Allowlist check
if (config.allowedList !== undefined) {
if (config.allowedList && config.allowedList.length > 0) {
const { found, deprecationWarning } = isInSettingsList(
normalizedId,
config.allowedList,
-71
View File
@@ -1109,77 +1109,6 @@ describe('Settings Loading and Merging', () => {
});
});
describe('LoadedSettings MCP consolidation', () => {
it('should consolidate mcp excluded list across all scopes', () => {
const loaded = new LoadedSettings(
{
path: '',
settings: { mcp: { excluded: ['system-excluded'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { excluded: ['defaults-excluded'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { excluded: ['user-excluded'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { excluded: ['workspace-excluded'] } },
originalSettings: {},
},
true,
);
expect(loaded.getConsolidatedExcludedMcpServers()).toEqual([
'system-excluded',
'defaults-excluded',
'user-excluded',
'workspace-excluded',
]);
});
it('should consolidate allowed mcp list via case-insensitive intersection', () => {
const loaded = new LoadedSettings(
{
path: '',
settings: { mcp: { allowed: ['Server-A', 'Server-B'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { allowed: ['server-a', 'Server-C'] } },
originalSettings: {},
},
{ path: '', settings: {}, originalSettings: {} }, // no allowlist in user
{
path: '',
settings: { mcp: { allowed: ['SERVER-A', 'Server-D'] } },
originalSettings: {},
},
true,
);
expect(loaded.getConsolidatedAllowedMcpServers()).toEqual(['Server-A']);
});
it('should return undefined allowed list if no scopes define one', () => {
const loaded = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
);
expect(loaded.getConsolidatedAllowedMcpServers()).toBeUndefined();
});
});
describe('compressionThreshold settings', () => {
it.each([
{
-45
View File
@@ -509,51 +509,6 @@ export class LoadedSettings {
this._remoteAdminSettings = { admin };
this._merged = this.computeMergedSettings();
}
/**
* Returns a consolidated list of excluded MCP servers across all settings files.
*/
getConsolidatedExcludedMcpServers(): string[] {
const scopes = [
this.system,
this.systemDefaults,
this.user,
this.workspace,
];
return scopes.flatMap((scope) => {
const excluded = scope?.settings?.mcp?.excluded;
return Array.isArray(excluded) ? excluded : [];
});
}
/**
* Returns a consolidated list of allowed MCP servers (via intersection of all defined lists).
*/
getConsolidatedAllowedMcpServers(): string[] | undefined {
const scopes = [
this.system,
this.systemDefaults,
this.user,
this.workspace,
];
const definedAllowlists = scopes.flatMap((scope) => {
const allowed = scope?.settings?.mcp?.allowed;
return Array.isArray(allowed) ? [allowed] : [];
});
if (definedAllowlists.length === 0) {
return undefined;
}
return definedAllowlists.reduce((acc, current) => {
const normalizedCurrent = new Set(
current.map((item) => item.toLowerCase().trim()),
);
return acc.filter((item) =>
normalizedCurrent.has(item.toLowerCase().trim()),
);
});
}
}
function findEnvFile(
-2
View File
@@ -499,7 +499,6 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
skipExtensions: true,
loadedSettings: settings,
});
adminControlsListner.setConfig(partialConfig);
@@ -628,7 +627,6 @@ export async function main() {
config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
loadedSettings: settings,
});
loadConfigHandle?.end();
@@ -79,6 +79,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
useEffect(() => {
let ignore = false;
setState({ status: PlanStatus.Loading });
debugLogger.debug('usePlanContent loading plan:', planPath);
const load = async () => {
try {
@@ -126,6 +127,10 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
return;
}
debugLogger.debug(
'usePlanContent loaded successfully, length:',
content.length,
);
setState({ status: PlanStatus.Loaded, content });
} catch (err: unknown) {
if (ignore) return;
@@ -67,7 +67,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const useGemini3_5Flash = config?.hasGemini35FlashGAAccess?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
@@ -130,7 +129,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
.getModelConfigService()
.getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
@@ -164,7 +162,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
),
key: GEMINI_MODEL_ALIAS_AUTO,
},
@@ -184,7 +181,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
shouldShowPreviewModels,
manualModelSelected,
useGemini31,
useGemini3_5Flash,
useCustomToolModel,
hasAccessToProModel,
]);
@@ -199,7 +195,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
.getModelConfigService()
.getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
@@ -292,7 +287,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
useCustomToolModel,
hasAccessToProModel,
config,
@@ -353,49 +353,6 @@ describe('<ModelStatsDisplay />', () => {
unmount();
});
it('should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-3-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 5,
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 1,
},
roles: {},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).toContain('gemini-3.5-flash');
expect(output).not.toContain('gemini-3-flash');
expect(output).toMatchSnapshot();
unmount();
});
it('should handle models with long names (gemini-3-*-preview) without layout breaking', async () => {
const { lastFrame, unmount } = await renderWithMockedStats(
{
@@ -299,7 +299,7 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
},
...modelNames.map((name) => ({
key: name,
header: getDisplayString(name),
header: name,
flexGrow: 1,
renderCell: (row: StatRowData) => {
// Don't render anything for section headers in model columns
@@ -131,33 +131,6 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('resolves gemini-3-flash to gemini-3.5-flash in the model usage table', async () => {
const metrics = createTestMetrics({
models: {
'gemini-3-flash': {
api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 3000 },
tokens: {
input: 1000,
prompt: 2000,
candidates: 3000,
total: 5000,
cached: 500,
thoughts: 100,
tool: 50,
},
roles: {},
},
},
});
const { lastFrame } = await renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('gemini-3.5-flash');
expect(output).not.toContain('gemini-3-flash\u0020'); // Avoid matching parts of substrings if not intended
expect(output).toMatchSnapshot();
});
it('renders role breakdown correctly under models', async () => {
const metrics = createTestMetrics({
models: {
@@ -24,7 +24,7 @@ import {
import { computeSessionStats } from '../utils/computeStats.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { QuotaStats } from '../types.js';
import { LlmRole, getDisplayString } from '@google/gemini-cli-core';
import { LlmRole } from '@google/gemini-cli-core';
// A more flexible and powerful StatRow component
interface StatRowProps {
@@ -101,7 +101,7 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
Object.entries(models).forEach(([name, metrics]) => {
rows.push({
name,
displayName: getDisplayString(name),
displayName: name,
requests: metrics.api.totalRequests,
cachedTokens: metrics.tokens.cached.toLocaleString(),
inputTokens: metrics.tokens.prompt.toLocaleString(),
@@ -215,26 +215,3 @@ exports[`<ModelStatsDisplay /> > should render "no API calls" message when there
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ModelStatsDisplay /> > should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-3.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 30 │
│ ↳ Input 5 │
│ ↳ Cache Reads 5 (50.0%) │
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -292,30 +292,3 @@ exports[`<StatsDisplay /> > renders role breakdown correctly under models 1`] =
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > resolves gemini-3-flash to gemini-3.5-flash in the model usage table 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 3.0s │
│ » API Time: 3.0s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Use /model to view model quota information │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ gemini-3.5-flash 5 2,000 500 3,000 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -81,24 +81,4 @@ describe('useVim passthrough', () => {
expect(handled).toBe(false);
});
it.each(['H', 'M', 'Q', 'm'])(
'should ignore unmapped printable key %s in NORMAL mode',
async (sequence) => {
mockVimContext.vimMode = 'NORMAL';
const { result } = await renderHook(() =>
useVim(mockBuffer as TextBuffer),
);
let handled = false;
act(() => {
handled = result.current.handleInput(
createKey({ name: sequence, sequence, insertable: true }),
);
});
expect(handled).toBe(true);
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
},
);
});
+2 -8
View File
@@ -1486,14 +1486,8 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });
// Ignore unmapped Insertable keys in Normal Mode, but let
// modifier-key chords (ctrl/alt/cmd) fall through to other handlers.
if (
normalizedKey.insertable &&
!normalizedKey.ctrl &&
!normalizedKey.alt &&
!normalizedKey.cmd
) {
// Ignore any Insertable key in Normal Mode
if (normalizedKey.insertable) {
return true;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -33,7 +33,6 @@ export interface ModelPolicyOptions {
useGemini31?: boolean;
useGemini31FlashLite?: boolean;
useCustomToolModel?: boolean;
useGemini3_5Flash?: boolean;
}
const DEFAULT_ACTIONS: ModelPolicyActionMap = {
@@ -95,9 +94,6 @@ export function getModelPolicyChain(
PREVIEW_GEMINI_MODEL,
options.useGemini31,
options.useCustomToolModel,
true,
undefined,
options.useGemini3_5Flash,
);
return [
definePolicy({
@@ -54,7 +54,6 @@ export function resolvePolicyChain(
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? false;
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
// Capture the original family intent before any normalization or early downgrade.
const isOriginallyGemini3 = isGemini3Model(modelFromConfig, config);
@@ -66,7 +65,6 @@ export function resolvePolicyChain(
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
),
);
const isAutoPreferred = normalizedPreferredModel
@@ -84,7 +82,6 @@ export function resolvePolicyChain(
const context = {
useGemini3_1: useGemini31,
useCustomTools: useCustomToolModel,
useGemini3_5Flash,
};
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
@@ -139,7 +136,6 @@ export function resolvePolicyChain(
userTier: config.getUserTier(),
useGemini31,
useCustomToolModel,
useGemini3_5Flash,
});
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
@@ -150,7 +146,6 @@ export function resolvePolicyChain(
userTier: config.getUserTier(),
useGemini31,
useCustomToolModel,
useGemini3_5Flash,
});
}
} else {
@@ -19,7 +19,6 @@ export const ExperimentFlags = {
GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879,
DEFAULT_REQUEST_TIMEOUT: 45773134,
GEMINI_3_5_FLASH_GA_LAUNCHED: 45780819,
} as const;
export type ExperimentFlagName =
-55
View File
@@ -69,7 +69,6 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
} from './models.js';
import { Storage } from './storage.js';
import type { AgentLoopContext } from './agent-loop-context.js';
@@ -4347,57 +4346,3 @@ describe('ADKSettings', () => {
expect(config.getAgentSessionNoninteractiveEnabled()).toBe(true);
});
});
describe('hasGemini35FlashGAAccess model setting', () => {
const baseParams: ConfigParameters = {
sessionId: 'test',
targetDir: '.',
debugMode: false,
model: 'test-model',
cwd: '.',
};
it('should set DEFAULT_GEMINI_FLASH_MODEL to gemini-3.5-flash and PREVIEW_GEMINI_FLASH_MODEL to gemini-3-flash-preview if hasGemini35FlashGAAccess returns true and authType is USE_GEMINI', () => {
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.USE_GEMINI };
// Set experiment to return true for GEMINI_3_5_FLASH_GA_LAUNCHED
config.setExperiments({
experimentIds: [],
flags: {
[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]: {
boolValue: true,
},
},
});
// Call the method
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash-preview');
});
it('should set DEFAULT_GEMINI_FLASH_MODEL and PREVIEW_GEMINI_FLASH_MODEL to gemini-3-flash if hasGemini35FlashGAAccess returns true and authType is not USE_GEMINI', () => {
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.LOGIN_WITH_GOOGLE };
// Set experiment to return true for GEMINI_3_5_FLASH_GA_LAUNCHED
config.setExperiments({
experimentIds: [],
flags: {
[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]: {
boolValue: true,
},
},
});
// Call the method
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3-flash');
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash');
});
});
-37
View File
@@ -86,7 +86,6 @@ import {
isGemini2Model,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
setFlashModels,
} from './models.js';
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
@@ -2055,7 +2054,6 @@ export class Config implements McpContext, AgentLoopContext {
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
const isPreview = isPreviewModel(primaryModel, this);
@@ -2095,7 +2093,6 @@ export class Config implements McpContext, AgentLoopContext {
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
return this.modelQuotas.get(primaryModel)?.remaining;
}
@@ -2111,7 +2108,6 @@ export class Config implements McpContext, AgentLoopContext {
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
return this.modelQuotas.get(primaryModel)?.limit;
}
@@ -2127,7 +2123,6 @@ export class Config implements McpContext, AgentLoopContext {
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
return this.modelQuotas.get(primaryModel)?.resetTime;
}
@@ -3542,38 +3537,6 @@ export class Config implements McpContext, AgentLoopContext {
);
}
/**
* Returns whether Gemini 3.5 Flash GA has been launched.
*
* Note: This method should only be called after startup, once experiments have been loaded.
*/
hasGemini35FlashGAAccess(): boolean {
const authType = this.contentGeneratorConfig?.authType;
const hasAccess = (() => {
if (this.isGemini31LaunchedForAuthType(authType)) {
return true;
}
return (
this.experiments?.flags[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]
?.boolValue ?? false
);
})();
// Used to set default flash models based on access
// TODO: Remove once the experiment for 3_5 flash rollut can be cleaned up.
if (hasAccess) {
// Gemini API key users should have the ability to manually select the
// old preview flash model.
if (authType === AuthType.USE_GEMINI) {
setFlashModels('gemini-3-flash-preview', 'gemini-3.5-flash');
} else {
setFlashModels('gemini-3-flash', 'gemini-3-flash');
}
} else {
setFlashModels('gemini-3-flash-preview', 'gemini-2.5-flash');
}
return hasAccess;
}
/**
* Returns whether Gemini 3.1 has been launched.
*
@@ -113,12 +113,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3.1-flash-lite',
},
},
'gemini-3.5-flash': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.5-flash',
},
},
'gemma-4-31b-it': {
extends: 'chat-base-3',
modelConfig: {
@@ -145,12 +139,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.5-flash-base': {
extends: 'base',
modelConfig: {
model: 'gemini-3.5-flash',
},
},
classifier: {
extends: 'base',
modelConfig: {
@@ -358,13 +346,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
},
'gemini-3.5-flash': {
tier: 'flash',
family: 'gemini-3',
isPreview: false,
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
family: 'gemini-2.5',
@@ -470,34 +451,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
default: 'gemini-3-flash-preview',
contexts: [
{
condition: { hasAccessToPreview: false, useGemini3_5Flash: true },
target: 'gemini-3.5-flash',
},
{
condition: { hasAccessToPreview: false, useGemini3_5Flash: false },
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
},
],
},
'gemini-3.5-flash': {
default: 'gemini-3.5-flash',
contexts: [
{
condition: { useGemini3_5Flash: false, hasAccessToPreview: false },
target: 'gemini-2.5-flash',
},
{
condition: { useGemini3_5Flash: false },
target: 'gemini-3-flash-preview',
},
],
},
'gemini-2.5-flash': {
default: 'gemini-2.5-flash',
contexts: [
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
],
},
'gemini-3-pro-preview': {
default: 'gemini-3-pro-preview',
contexts: [
@@ -546,7 +504,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
flash: {
default: 'gemini-3-flash-preview',
contexts: [
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
{
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
@@ -578,7 +535,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
flash: {
default: 'gemini-3-flash-preview',
contexts: [
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
{
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
-305
View File
@@ -17,7 +17,6 @@ import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_3_5_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
supportsMultimodalFunctionResponse,
GEMINI_MODEL_ALIAS_PRO,
@@ -745,308 +744,4 @@ describe('getAutoModelDescription', () => {
expect(desc).toContain('gemini-3.1-pro-preview');
expect(desc).toContain('gemini-3-flash-preview');
});
it('should return Gemini 3.5 Flash description when hasAccessToPreview and useGemini3_5Flash are true', () => {
const desc = getAutoModelDescription(true, true, true);
expect(desc).toContain('gemini-3.1-pro-preview');
expect(desc).toContain(DEFAULT_GEMINI_3_5_FLASH_MODEL);
});
});
describe('resolveModel Gemini 3.5 Flash GA', () => {
it('should resolve all but preview flash models to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (legacy)', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
true,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should resolve all but preview flash models to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should NOT resolve flash models to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is false', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
false,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
false,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
false,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should resolve to DEFAULT_GEMINI_FLASH_MODEL when GA is false AND preview access is false (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
false, // No preview access
mockDynamicConfig,
false, // GA false
),
).toBe('gemini-2.5-flash');
});
it('should resolve auto to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true and classifier selects flash', () => {
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve auto to gemini-3.5-flash when useGemini3_5Flash is true and classifier selects flash (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
describe('Flash model promotion and manual override routing logic', () => {
it('should resolve flash alias to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (static)', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve flash alias to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
it('should resolve manual selection of gemini-3-flash-preview to gemini-3-flash-preview when useGemini3_5Flash is true and has preview access (static)', () => {
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
true,
),
).toBe('gemini-3-flash-preview');
});
it('should resolve manual selection of gemini-3-flash-preview to gemini-3-flash-preview when useGemini3_5Flash is true and has preview access (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3-flash-preview');
});
it('should resolve manual selection of gemini-3-flash-preview to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true but lacks preview access (static)', () => {
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
false,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve manual selection of gemini-3-flash-preview to gemini-3.5-flash when useGemini3_5Flash is true but lacks preview access (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
false,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
it('should resolve classifier-selected flash alias to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (static)', () => {
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve classifier-selected flash alias to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
it('should resolve auto to PREVIEW_GEMINI_MODEL when useGemini3_5Flash is true and has preview access', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_AUTO,
false,
false,
true, // hasAccessToPreview
undefined,
true, // useGemini3_5Flash
),
).toBe(PREVIEW_GEMINI_MODEL);
});
});
});
+5 -65
View File
@@ -6,7 +6,6 @@
export interface ModelResolutionContext {
useGemini3_1?: boolean;
useGemini3_5Flash?: boolean;
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
requestedModel?: string;
@@ -55,29 +54,9 @@ export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
'gemini-3.1-pro-preview-customtools';
// TODO: set to none and const once the experiment for 3_5 flash rollut can be
// cleaned up.
export let PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
// TODO: Set to const and update to 'gemini-3.5-flash' once the experiment for
// 3_5 flash rollut can be cleaned up.
// This is set to either the same as the DEFAULT_GEMINI_3_5_FLASH_MODEL const
// OR the SECONDARY_GEMINI_3_5_FLASH_MODEL depending on which is needed for
// the user's backend as determined by hasGemini35FlashGAAccess in
// packages/core/src/config/config.ts
export let DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_3_5_FLASH_MODEL = 'gemini-3.5-flash';
// This is resolved to 3.5 flash in backends where it is used,
// however those backends do not expect to see the string gemini-3.5-flash
// so we need to provide this model as an alternative name in certain instances.
export const SECONDARY_GEMINI_3_5_FLASH_MODEL = 'gemini-3-flash';
// Used to set default flash models based on access
// TODO: Cleanup once the experiment for 3_5 flash rollut can be cleaned up.
export function setFlashModels(preview: string, defaultFlash: string) {
PREVIEW_GEMINI_FLASH_MODEL = preview;
DEFAULT_GEMINI_FLASH_MODEL = defaultFlash;
}
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-3.1-flash-lite';
/** @deprecated Gemini 3.1 Flash Lite is now GA. Use DEFAULT_GEMINI_FLASH_LITE_MODEL. */
export const PREVIEW_GEMINI_FLASH_LITE_MODEL = 'none';
@@ -93,8 +72,6 @@ export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_3_5_FLASH_MODEL,
SECONDARY_GEMINI_3_5_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMMA_4_31B_IT_MODEL,
@@ -120,7 +97,6 @@ export const DEFAULT_THINKING_MODE = 8192;
export function getAutoModelDescription(
hasAccessToPreview: boolean,
useGemini3_1: boolean = false,
useGemini3_5Flash: boolean = false,
) {
const proModel = hasAccessToPreview
? useGemini3_1
@@ -128,11 +104,9 @@ export function getAutoModelDescription(
: PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL;
const flashModel = hasAccessToPreview
? useGemini3_5Flash
? DEFAULT_GEMINI_3_5_FLASH_MODEL
: PREVIEW_GEMINI_FLASH_MODEL
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
return `Let Gemini CLI decide the best model for the task: ${getDisplayString(proModel)}, ${getDisplayString(flashModel)}`;
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
}
/**
@@ -141,7 +115,6 @@ export function getAutoModelDescription(
*
* @param requestedModel The model alias or concrete model name requested by the user.
* @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases.
* @param useGemini3_5Flash Whether to use Gemini 3.5 Flash GA.
* @param hasAccessToPreview Whether the user has access to preview models.
* @returns The resolved concrete model name.
*/
@@ -151,7 +124,6 @@ export function resolveModel(
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
useGemini3_5Flash: boolean = false,
): string {
// Defensive check against non-string inputs at runtime
const normalizedModel = Array.isArray(requestedModel)
@@ -165,7 +137,6 @@ export function resolveModel(
useGemini3_1,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
useGemini3_5Flash,
});
if (!hasAccessToPreview && isPreviewModel(resolved, config)) {
@@ -208,9 +179,7 @@ export function resolveModel(
break;
}
case GEMINI_MODEL_ALIAS_FLASH: {
resolved = useGemini3_5Flash
? DEFAULT_GEMINI_FLASH_MODEL
: PREVIEW_GEMINI_FLASH_MODEL;
resolved = PREVIEW_GEMINI_FLASH_MODEL;
break;
}
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
@@ -227,14 +196,6 @@ export function resolveModel(
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
}
if (
useGemini3_5Flash &&
isFlashModel(resolved) &&
normalizedModel !== PREVIEW_GEMINI_FLASH_MODEL
) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
if (!hasAccessToPreview && isPreviewModel(resolved)) {
// Downgrade to stable models if user lacks preview access.
switch (resolved) {
@@ -259,17 +220,6 @@ export function resolveModel(
return resolved;
}
function isFlashModel(model: string): boolean {
return (
model === DEFAULT_GEMINI_FLASH_MODEL ||
model === PREVIEW_GEMINI_FLASH_MODEL ||
model === DEFAULT_GEMINI_3_5_FLASH_MODEL ||
model === SECONDARY_GEMINI_3_5_FLASH_MODEL ||
model === 'flash' ||
model.endsWith('flash')
);
}
/**
* Resolves the appropriate model based on the classifier's decision.
*
@@ -287,7 +237,6 @@ export function resolveClassifierModel(
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
useGemini3_5Flash: boolean = false,
): string {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.resolveClassifierModelId(
@@ -297,7 +246,6 @@ export function resolveClassifierModel(
useGemini3_1,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
useGemini3_5Flash,
},
);
}
@@ -314,9 +262,6 @@ export function resolveClassifierModel(
requestedModel === PREVIEW_GEMINI_MODEL ||
requestedModel === GEMINI_MODEL_ALIAS_AUTO
) {
if (useGemini3_5Flash) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return hasAccessToPreview
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
@@ -326,8 +271,6 @@ export function resolveClassifierModel(
false,
false,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
}
return resolveModel(
@@ -336,7 +279,6 @@ export function resolveClassifierModel(
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
}
@@ -352,8 +294,6 @@ export function getDisplayString(
}
switch (model) {
case 'gemini-3-flash':
return DEFAULT_GEMINI_3_5_FLASH_MODEL;
case GEMINI_MODEL_ALIAS_AUTO:
return 'Auto';
case PREVIEW_GEMINI_MODEL_AUTO:
-1
View File
@@ -607,7 +607,6 @@ export class GeminiClient {
false,
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
this.config.hasGemini35FlashGAAccess?.() ?? false,
);
}
@@ -221,7 +221,6 @@ export async function createContentGenerator(
false,
gcConfig.getHasAccessToPreviewModel?.() ?? true,
gcConfig,
gcConfig.hasGemini35FlashGAAccess?.() ?? false,
);
const customHeadersEnv =
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
@@ -159,7 +159,6 @@ describe('GeminiChat', () => {
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getUsageStatisticsEnabled: () => true,
hasGemini35FlashGAAccess: vi.fn().mockReturnValue(false),
getDebugMode: () => false,
getContentGeneratorConfig: vi.fn().mockImplementation(() => ({
authType: 'oauth-personal',
+1 -3
View File
@@ -721,6 +721,7 @@ export class GeminiChat {
(await this.context.config.getGemini31Launched?.()) ?? false;
const hasAccessToPreview =
this.context.config.getHasAccessToPreviewModel?.() ?? true;
// Default to the last used model (which respects arguments/availability selection)
let modelToUse = resolveModel(
lastModelToUse,
@@ -728,7 +729,6 @@ export class GeminiChat {
false,
hasAccessToPreview,
this.context.config,
this.context.config.hasGemini35FlashGAAccess?.() ?? false,
);
// If the active model has changed (e.g. due to a fallback updating the config),
@@ -740,7 +740,6 @@ export class GeminiChat {
false,
hasAccessToPreview,
this.context.config,
this.context.config.hasGemini35FlashGAAccess?.() ?? false,
);
}
@@ -803,7 +802,6 @@ export class GeminiChat {
false,
hasAccessToPreview,
this.context.config,
this.context.config.hasGemini35FlashGAAccess?.() ?? false,
);
lastModelToUse = modelToUse;
// Re-evaluate contentsToUse based on the new model's feature support
@@ -98,7 +98,6 @@ describe('GeminiChat Network Retries', () => {
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
getUsageStatisticsEnabled: () => true,
hasGemini35FlashGAAccess: vi.fn().mockReturnValue(false),
getDebugMode: () => false,
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'oauth-personal',
@@ -76,7 +76,6 @@ export class PromptProvider {
false,
context.config.getHasAccessToPreviewModel?.() ?? true,
context.config,
context.config.hasGemini35FlashGAAccess?.() ?? false,
);
const isModernModel = supportsModernFeatures(desiredModel);
const activeSnippets = isModernModel ? snippets : legacySnippets;
@@ -300,7 +299,6 @@ export class PromptProvider {
false,
context.config.getHasAccessToPreviewModel?.() ?? true,
context.config,
context.config.hasGemini35FlashGAAccess?.() ?? false,
);
const isModernModel = supportsModernFeatures(desiredModel);
const activeSnippets = isModernModel ? snippets : legacySnippets;
@@ -242,22 +242,4 @@ describe('ApprovalModeStrategy', () => {
// Should resolve to Preview Flash (3.0) because resolveClassifierModel uses preview variants for Gemini 3
expect(decision?.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should route to DEFAULT_GEMINI_FLASH_MODEL when hasGemini35FlashGAAccess is true and plan is approved', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
mockConfig.hasGemini35FlashGAAccess = vi.fn().mockReturnValue(true);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
'/path/to/plan.md',
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
@@ -54,7 +54,6 @@ export class ApprovalModeStrategy implements RoutingStrategy {
config.getUseCustomToolModel(),
config.getHasAccessToPreviewModel(),
]);
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
// 1. Planning Phase: If ApprovalMode === PLAN, explicitly route to the Pro model.
if (approvalMode === ApprovalMode.PLAN) {
@@ -65,7 +64,6 @@ export class ApprovalModeStrategy implements RoutingStrategy {
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
return {
model: proModel,
@@ -84,7 +82,6 @@ export class ApprovalModeStrategy implements RoutingStrategy {
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
return {
model: flashModel,
@@ -386,97 +386,6 @@ describe('ClassifierStrategy', () => {
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return null (bypass classifier) if history is only tool turns and request is a function response', async () => {
const history: Content[] = [
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
{
role: 'user',
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
},
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
];
mockContext.history = history;
mockContext.request = [
{ functionResponse: { name: 'tool2', response: { ok: true } } },
];
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).toBeNull();
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should return null (bypass classifier) if history has text turns and request is a function response', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'some task' }] },
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
];
mockContext.history = history;
mockContext.request = [
{ functionResponse: { name: 'tool', response: { ok: true } } },
];
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).toBeNull();
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should still route if history is only tool turns but request is text', async () => {
const history: Content[] = [
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
{
role: 'user',
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
},
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
];
mockContext.history = history;
mockContext.request = [{ text: 'simple task' }];
const mockApiResponse = {
reasoning: 'Simple.',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).not.toBeNull();
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
.calls[0][0];
const contents = generateJsonCall.contents;
// History should be empty because all turns were tool turns and stripped.
// Request should be present.
const expectedContents = [
{
role: 'user',
parts: [{ text: 'simple task' }],
},
];
expect(contents).toEqual(expectedContents);
});
describe('Gemini 3.1 and Custom Tools Routing', () => {
it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
@@ -522,27 +431,5 @@ describe('ClassifierStrategy', () => {
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
it('should route to DEFAULT_GEMINI_FLASH_MODEL when hasGemini35FlashGAAccess is true', async () => {
mockConfig.hasGemini35FlashGAAccess = vi.fn().mockReturnValue(true);
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
const mockApiResponse = {
reasoning: 'Simple task',
model_choice: 'flash',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
});
@@ -145,22 +145,12 @@ export class ClassifierStrategy implements RoutingStrategy {
return null;
}
// TODO - Consider using function req/res if they help accuracy.
// Bypass the classifier if the request is a function response.
// Since we prune all tool turns from history, sending a function response
// request would result in an invalid payload (missing the preceding function call).
if (isFunctionResponse(createUserContent(context.request))) {
debugLogger.log(
'[Routing] Bypassing Classifier: request is FunctionResponse.',
);
return null;
}
const promptId = getPromptIdWithFallback('classifier-router');
const historySlice = context.history.slice(-HISTORY_SEARCH_WINDOW);
// Filter out tool-related turns.
// TODO - Consider using function req/res if they help accuracy.
const cleanHistory = historySlice.filter(
(content) => !isFunctionCall(content) && !isFunctionResponse(content),
);
@@ -186,7 +176,6 @@ export class ClassifierStrategy implements RoutingStrategy {
config.getGemini31Launched(),
config.getUseCustomToolModel(),
]);
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
const selectedModel = normalizeModelId(
resolveClassifierModel(
normalizeModelId(model),
@@ -195,7 +184,6 @@ export class ClassifierStrategy implements RoutingStrategy {
useCustomToolModel,
config.getHasAccessToPreviewModel?.() ?? true,
config,
useGemini3_5Flash,
),
);
@@ -29,7 +29,6 @@ export class DefaultStrategy implements TerminalStrategy {
false,
config.getHasAccessToPreviewModel?.() ?? true,
config,
config.hasGemini35FlashGAAccess?.() ?? false,
);
return {
model: defaultModel,
@@ -31,7 +31,6 @@ export class FallbackStrategy implements RoutingStrategy {
false,
config.getHasAccessToPreviewModel?.() ?? true,
config,
config.hasGemini35FlashGAAccess?.() ?? false,
);
const service = config.getModelAvailabilityService();
const snapshot = service.snapshot(resolvedModel);
@@ -12,7 +12,6 @@ import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../../config/models.js';
import type { Content } from '@google/genai';
import { debugLogger } from '../../utils/debugLogger.js';
@@ -324,24 +323,4 @@ second message
expect(lastTurn!.parts!.at(0)!.text).toEqual(expectedLastTurn);
});
it('should route to DEFAULT_GEMINI_FLASH_MODEL when hasGemini35FlashGAAccess is true', async () => {
mockConfig.hasGemini35FlashGAAccess = vi.fn().mockReturnValue(true);
mockConfig.getModel = () => PREVIEW_GEMINI_MODEL_AUTO;
const mockApiResponse = {
reasoning: 'Simple task',
model_choice: 'flash',
};
mockGenerateJson.mockResolvedValue(mockApiResponse);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
@@ -216,7 +216,6 @@ ${formattedHistory}
config.getUseCustomToolModel(),
config.getHasAccessToPreviewModel(),
]);
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
const selectedModel = resolveClassifierModel(
context.requestedModel ?? config.getModel(),
@@ -225,7 +224,6 @@ ${formattedHistory}
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
return {
@@ -20,7 +20,6 @@ import {
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
} from '../../config/models.js';
import { promptIdContext } from '../../utils/promptIdContext.js';
import type { Content } from '@google/genai';
@@ -476,105 +475,6 @@ describe('NumericalClassifierStrategy', () => {
expect(contents).toEqual(expectedContents);
});
it('should return null (bypass classifier) if history is only tool turns and request is a function response', async () => {
const history: Content[] = [
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
{
role: 'user',
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
},
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
];
mockContext.history = history;
mockContext.request = [
{ functionResponse: { name: 'tool2', response: { ok: true } } },
];
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).toBeNull();
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should still route if history is only tool turns but request is text', async () => {
const history: Content[] = [
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
{
role: 'user',
parts: [{ functionResponse: { name: 'tool', response: { ok: true } } }],
},
{ role: 'model', parts: [{ functionCall: { name: 'tool2' } }] },
];
mockContext.history = history;
mockContext.request = [{ text: 'simple task' }];
const mockApiResponse = {
complexity_reasoning: 'Simple.',
complexity_score: 10,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).not.toBeNull();
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock
.calls[0][0];
const contents = generateJsonCall.contents;
// History should be empty because all turns were tool turns and stripped.
// Request should be present.
const expectedContents = [
{
role: 'user',
parts: [{ text: 'simple task' }],
},
];
expect(contents).toEqual(expectedContents);
});
it('should still route if history has text turns and request is a function response', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'some task' }] },
{ role: 'model', parts: [{ functionCall: { name: 'tool' } }] },
];
mockContext.history = history;
mockContext.request = [
{ functionResponse: { name: 'tool', response: { ok: true } } },
];
const mockApiResponse = {
complexity_reasoning: 'Simple.',
complexity_score: 10,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision).not.toBeNull();
expect(mockBaseLlmClient.generateJson).toHaveBeenCalled();
});
it('should preserve tool turns when they appear after a non-tool turn in the middle of history', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'turn 0 (before)' }] },
@@ -895,27 +795,5 @@ describe('NumericalClassifierStrategy', () => {
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
it('should route to DEFAULT_GEMINI_FLASH_MODEL when hasGemini35FlashGAAccess is true', async () => {
mockConfig.hasGemini35FlashGAAccess = vi.fn().mockReturnValue(true);
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
const mockApiResponse = {
complexity_reasoning: 'Simple task',
complexity_score: 10,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
mockLocalLiteRtLmClient,
);
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
});
});
@@ -142,19 +142,6 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
? context.request
: [context.request];
// Bypass the classifier if the request is a function response and history is empty.
// Since we prune leading tool turns, if the history becomes empty, sending a
// function response request would result in an invalid payload (starts with function response).
if (
finalHistory.length === 0 &&
isFunctionResponse(createUserContent(context.request))
) {
debugLogger.log(
'[Routing] Bypassing NumericalClassifier: request is FunctionResponse but history is empty after slicing.',
);
return null;
}
const sanitizedRequest = requestParts.map((part) => {
if (typeof part === 'string') {
return { text: part };
@@ -184,7 +171,6 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
config.getGemini31Launched(),
config.getUseCustomToolModel(),
]);
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
const selectedModel = normalizeModelId(
resolveClassifierModel(
normalizeModelId(model),
@@ -193,7 +179,6 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
useCustomToolModel,
config.getHasAccessToPreviewModel?.() ?? true,
config,
useGemini3_5Flash,
),
);
@@ -41,7 +41,6 @@ export class OverrideStrategy implements RoutingStrategy {
false,
config.getHasAccessToPreviewModel?.() ?? true,
config,
config.hasGemini35FlashGAAccess?.() ?? false,
),
metadata: {
source: this.name,
@@ -813,6 +813,33 @@ describe('policy.ts', () => {
}),
);
});
it('should map ProceedAlways to ProceedOnce in Plan Mode', async () => {
const mockConfig = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.PLAN),
setApprovalMode: vi.fn(),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Mocked<Config>;
(mockConfig as unknown as { config: Config }).config =
mockConfig as Config;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
mockMessageBus;
const tool = { name: 'replace' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlways,
undefined,
mockConfig,
mockMessageBus,
);
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
});
describe('getPolicyDenialError', () => {
+8
View File
@@ -121,6 +121,14 @@ export async function updatePolicy(
): Promise<void> {
const currentMode = context.config.getApprovalMode();
// If in Plan Mode, map 'Proceed Always' (Allow for this session) to 'Proceed Once' (Allow once)
// to prevent transitioning to AUTO_EDIT mode and updating policy.
if (
currentMode === ApprovalMode.PLAN &&
outcome === ToolConfirmationOutcome.ProceedAlways
) {
outcome = ToolConfirmationOutcome.ProceedOnce;
}
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
context.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
@@ -1044,79 +1044,6 @@ describe('ModelConfigService', () => {
});
});
// Resolves a model ID to a concrete model ID based on the provided context.
describe('resolveModelId', () => {
it('should resolve based on useGemini3_5Flash condition', () => {
const config: ModelConfigServiceConfig = {
modelIdResolutions: {
flash: {
default: 'gemini-2.0-flash',
contexts: [
{
condition: { useGemini3_5Flash: true },
target: 'gemini-3.5-flash',
},
],
},
},
};
const service = new ModelConfigService(config);
expect(service.resolveModelId('flash', { useGemini3_5Flash: true })).toBe(
'gemini-3.5-flash',
);
expect(
service.resolveModelId('flash', { useGemini3_5Flash: false }),
).toBe('gemini-2.0-flash');
expect(service.resolveModelId('flash', {})).toBe('gemini-2.0-flash');
});
it('should resolve based on complex conditions including useGemini3_5Flash', () => {
const config: ModelConfigServiceConfig = {
modelIdResolutions: {
'gemini-flash': {
default: 'gemini-3-flash-preview',
contexts: [
{
condition: {
useGemini3_5Flash: false,
hasAccessToPreview: false,
},
target: 'gemini-2.5-flash',
},
{
condition: { useGemini3_5Flash: true },
target: 'gemini-3.5-flash',
},
],
},
},
};
const service = new ModelConfigService(config);
// Case 1: GA Access granted
expect(
service.resolveModelId('gemini-flash', { useGemini3_5Flash: true }),
).toBe('gemini-3.5-flash');
// Case 2: GA Access denied, but has preview access
expect(
service.resolveModelId('gemini-flash', {
useGemini3_5Flash: false,
hasAccessToPreview: true,
}),
).toBe('gemini-3-flash-preview');
// Case 3: GA Access denied AND no preview access
expect(
service.resolveModelId('gemini-flash', {
useGemini3_5Flash: false,
hasAccessToPreview: false,
}),
).toBe('gemini-2.5-flash');
});
});
describe('getAvailableModelOptions', () => {
it('should filter out Pro models when hasAccessToProModel is false', () => {
const config: ModelConfigServiceConfig = {
@@ -97,7 +97,6 @@ export interface ModelResolution {
export interface ResolutionContext {
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
useGemini3_5Flash?: boolean;
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
hasAccessToProModel?: boolean;
@@ -108,7 +107,6 @@ export interface ResolutionContext {
export interface ResolutionCondition {
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
useGemini3_5Flash?: boolean;
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
/** Matches if the current model is in this list. */
@@ -157,7 +155,6 @@ export class ModelConfigService {
const definitions = this.config.modelDefinitions ?? {};
const shouldShowPreviewModels = context.hasAccessToPreview ?? false;
const useGemini31 = context.useGemini3_1 ?? false;
const useGemini3_5Flash = context.useGemini3_5Flash ?? false;
const mainOptions = Object.entries(definitions)
.filter(([_, m]) => {
@@ -172,7 +169,6 @@ export class ModelConfigService {
description = getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
);
} else if (id === 'auto-gemini-3' && useGemini31) {
description = description.replace('gemini-3-pro', 'gemini-3.1-pro');
@@ -254,8 +250,6 @@ export class ModelConfigService {
return value === context.useGemini3_1;
case 'useGemini3_1FlashLite':
return value === context.useGemini3_1FlashLite;
case 'useGemini3_5Flash':
return value === context.useGemini3_5Flash;
case 'useCustomTools':
return value === context.useCustomTools;
case 'hasAccessToPreview':
@@ -37,7 +37,6 @@ import {
} from './sandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { killProcessGroup } from '../utils/process-utils.js';
import { isNodeError } from '../utils/errors.js';
import {
ExecutionLifecycleService,
type ExecutionHandle,
@@ -1508,45 +1507,30 @@ export class ShellExecutionService {
}
const activePty = this.activePtys.get(pid);
if (!activePty) {
return;
}
// Skip Windows: process.kill(pid, 0) is heavy and native errors are catchable there.
if (process.platform !== 'win32') {
if (activePty) {
try {
process.kill(pid, 0);
activePty.ptyProcess.resize(cols, rows);
activePty.headlessTerminal.resize(cols, rows);
} catch (e) {
// Bail only if the process is explicitly confirmed dead (ESRCH).
if (isNodeError(e) && e.code === 'ESRCH') {
return;
// Ignore errors if the pty has already exited, which can happen
// due to a race condition between the exit event and this call.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = e as { code?: string; message?: string };
const isEsrch = err.code === 'ESRCH';
const isWindowsPtyError = err.message?.includes(
'Cannot resize a pty that has already exited',
);
if (isEsrch || isWindowsPtyError) {
// On Unix, we get an ESRCH error.
// On Windows, we get a message-based error.
// In both cases, it's safe to ignore.
} else {
throw e;
}
}
}
try {
activePty.ptyProcess.resize(cols, rows);
activePty.headlessTerminal.resize(cols, rows);
} catch (e) {
// Ignore errors if the pty has already exited, which can happen
// due to a race condition between the exit event and this call.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = e as { code?: string; message?: string };
const isEsrch = err.code === 'ESRCH';
const isEbadf = err.code === 'EBADF' || err.message?.includes('EBADF');
const isWindowsPtyError = err.message?.includes(
'Cannot resize a pty that has already exited',
);
if (isEsrch || isEbadf || isWindowsPtyError) {
// On Unix, we get an ESRCH or EBADF error.
// On Windows, we get a message-based error.
// In both cases, it's safe to ignore.
} else {
throw e;
}
}
// Force emit the new state after resize
if (activePty) {
const endLine = activePty.headlessTerminal.buffer.active.length;
@@ -145,18 +145,6 @@
"topK": 64
}
},
"gemini-3.5-flash": {
"model": "gemini-3.5-flash",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
@@ -195,13 +183,6 @@
"topP": 1
}
},
"gemini-3.5-flash-base": {
"model": "gemini-3.5-flash",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"classifier": {
"model": "flash-lite",
"generateContentConfig": {
@@ -145,18 +145,6 @@
"topK": 64
}
},
"gemini-3.5-flash": {
"model": "gemini-3.5-flash",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
@@ -195,13 +183,6 @@
"topP": 1
}
},
"gemini-3.5-flash-base": {
"model": "gemini-3.5-flash",
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
},
"classifier": {
"model": "flash-lite",
"generateContentConfig": {
+80 -2
View File
@@ -1371,6 +1371,14 @@ function doIt() {
});
describe('plan mode', () => {
beforeEach(() => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
});
afterEach(() => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(false);
});
it('should allow edits to plans directory when isPlanMode is true', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
@@ -1380,8 +1388,6 @@ function doIt() {
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = 'test-file.txt';
@@ -1408,5 +1414,77 @@ function doIt() {
fs.rmSync(plansDir, { recursive: true, force: true });
});
it('should preserve nested directory structure within the plans directory in Plan Mode', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planFilePath = path.join(nestedDir, 'spec.md');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: 'tracks/fibsqrt_20260519/spec.md',
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
it('should strip the leading plansDir folder name segment if present in path', async () => {
const mockProjectTempDir = path.join(tempDir, 'project');
fs.mkdirSync(mockProjectTempDir);
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
mockProjectTempDir,
);
const plansDir = path.join(mockProjectTempDir, 'plans');
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planFilePath = path.join(nestedDir, 'spec.md');
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: 'plans/tracks/fibsqrt_20260519/spec.md',
instruction: 'Replace initial with new',
old_string: 'initial',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(planFilePath, 'utf8')).toBe('some new content');
fs.rmSync(plansDir, { recursive: true, force: true });
});
});
});
@@ -515,5 +515,28 @@ Ask the user for specific feedback on how to improve the plan.`,
});
expect(result).toBeNull();
});
it('should accept nested valid path within plans directory', () => {
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
const result = tool.validateToolParams({
plan_filename: 'tracks/fibsqrt_20260519/spec.md',
});
expect(result).toBeNull();
});
it('should strip the leading plansDir folder name segment if present in path', () => {
const plansDirName = path.basename(mockPlansDir);
const nestedDir = path.join(mockPlansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, 'spec.md'), '# Content');
const result = tool.validateToolParams({
plan_filename: `${plansDirName}/tracks/fibsqrt_20260519/spec.md`,
});
expect(result).toBeNull();
});
});
});
+3 -12
View File
@@ -226,28 +226,19 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const exitMessage = getPlanModeExitMessage(newMode);
return {
llmContent: `${exitMessage}
The approved implementation plan is stored at: ${resolvedPlanPath}
Read and follow the plan strictly during implementation.`,
llmContent: `${exitMessage}\n\nThe approved implementation plan is stored at: ${resolvedPlanPath}\nRead and follow the plan strictly during implementation.`,
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
};
} else {
const feedback = payload?.feedback?.trim();
if (feedback) {
return {
llmContent: `Plan rejected. User feedback: ${feedback}
The plan is stored at: ${resolvedPlanPath}
Revise the plan based on the feedback.`,
llmContent: `Plan rejected. User feedback: ${feedback}\n\nThe plan is stored at: ${resolvedPlanPath}\nRevise the plan based on the feedback.`,
returnDisplay: `Feedback: ${feedback}`,
};
} else {
return {
llmContent: `Plan rejected. No feedback provided.
The plan is stored at: ${resolvedPlanPath}
Ask the user for specific feedback on how to improve the plan.`,
llmContent: `Plan rejected. No feedback provided.\n\nThe plan is stored at: ${resolvedPlanPath}\nAsk the user for specific feedback on how to improve the plan.`,
returnDisplay: 'Rejected (no feedback)',
};
}
+11 -17
View File
@@ -1061,24 +1061,18 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
// Only clean up if NOT running in background.
// Background processes need the temp directory and PID file to remain
// available until they exit.
if (!this.params.is_background) {
if (tempFilePath) {
try {
await fsPromises.unlink(tempFilePath);
} catch {
// Ignore errors during unlink
}
if (tempFilePath) {
try {
await fsPromises.unlink(tempFilePath);
} catch {
// Ignore errors during unlink
}
if (tempDir) {
try {
await fsPromises.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore errors during rm
}
}
if (tempDir) {
try {
await fsPromises.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore errors during rm
}
}
}
@@ -110,6 +110,7 @@ const mockConfigInternal = {
getActiveModel: () => 'test-model',
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
},
};
@@ -148,6 +149,7 @@ describe('WriteFileTool', () => {
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
const mockStorage = {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue(plansDir),
};
mockConfig = {
@@ -1146,4 +1148,38 @@ describe('WriteFileTool', () => {
expect(fs.readFileSync(expectedWritePath, 'utf8')).toBe('nested content');
});
});
describe('Plan Mode path resolution', () => {
beforeEach(() => {
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfigInternal.storage.getPlansDir).mockReturnValue(
plansDir,
);
});
afterEach(() => {
vi.mocked(mockConfigInternal.isPlanMode).mockReturnValue(false);
});
it('should preserve nested directory structure within the plans directory', () => {
const planFilePath = 'tracks/fibsqrt_20260519/spec.md';
const params = { file_path: planFilePath, content: '# Spec' };
const invocation = tool.build(params);
expect(
(invocation as unknown as { resolvedPath: string }).resolvedPath,
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
});
it('should strip the leading plansDir folder name segment if present in path', () => {
const plansDirName = path.basename(plansDir);
const planFilePath = `${plansDirName}/tracks/fibsqrt_20260519/spec.md`;
const params = { file_path: planFilePath, content: '# Spec' };
const invocation = tool.build(params);
expect(
(invocation as unknown as { resolvedPath: string }).resolvedPath,
).toBe(path.resolve(plansDir, 'tracks/fibsqrt_20260519/spec.md'));
});
});
});
+55 -1
View File
@@ -8,7 +8,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import * as fs from 'node:fs';
import os from 'node:os';
import { validatePlanPath, validatePlanContent } from './planUtils.js';
import {
validatePlanPath,
validatePlanContent,
resolveAndValidatePlanPath,
} from './planUtils.js';
describe('planUtils', () => {
let tempRootDir: string;
@@ -63,6 +67,56 @@ describe('planUtils', () => {
);
expect(result).toContain('Access denied');
});
it('should validate a nested path within the plans directory', async () => {
const nestedDir = path.join(plansDir, 'tracks', 'fibsqrt_20260519');
fs.mkdirSync(nestedDir, { recursive: true });
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
const fullPath = path.join(plansDir, planPath);
fs.writeFileSync(fullPath, '# Nested Spec');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBeNull();
});
});
describe('resolveAndValidatePlanPath', () => {
it('should resolve simple filenames relative to plansDir', () => {
const result = resolveAndValidatePlanPath(
'implementation_plan.md',
plansDir,
tempRootDir,
);
expect(result).toBe(path.join(plansDir, 'implementation_plan.md'));
});
it('should preserve subdirectories if already inside plansDir', () => {
const planPath = path.join(
'plans',
'tracks',
'fibsqrt_20260519',
'spec.md',
);
const result = resolveAndValidatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBe(
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
);
});
it('should resolve paths relative to plansDir if they contain subdirectories', () => {
const planPath = path.join('tracks', 'fibsqrt_20260519', 'spec.md');
const result = resolveAndValidatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBe(
path.join(plansDir, 'tracks', 'fibsqrt_20260519', 'spec.md'),
);
});
it('should throw access denied when escaping', () => {
const planPath = '../../escaped.md';
expect(() =>
resolveAndValidatePlanPath(planPath, plansDir, tempRootDir),
).toThrow(/Access denied/);
});
});
describe('validatePlanContent', () => {
+46 -25
View File
@@ -40,38 +40,59 @@ export function resolveAndValidatePlanPath(
throw new Error('Plan file path must be non-empty.');
}
// 1. Handle case where agent provided an absolute path
if (path.isAbsolute(trimmedPath)) {
if (
isSubpath(resolveToRealPath(plansDir), resolveToRealPath(trimmedPath))
) {
return trimmedPath;
const realPlansDir = resolveToRealPath(plansDir);
const plansDirName = path.basename(plansDir);
let normalizedPlanPath = trimmedPath;
if (!path.isAbsolute(trimmedPath)) {
const segments = trimmedPath.split(/[\\/]+/);
if (segments.length > 1 && segments[0] === plansDirName) {
normalizedPlanPath = segments.slice(1).join(path.sep);
}
}
// 2. Handle case where agent provided a path relative to the project root
const resolvedFromProjectRoot = path.resolve(projectRoot, trimmedPath);
if (
isSubpath(
resolveToRealPath(plansDir),
resolveToRealPath(resolvedFromProjectRoot),
)
) {
return resolvedFromProjectRoot;
// 1. Handle case where agent provided an absolute path
if (path.isAbsolute(normalizedPlanPath)) {
try {
const realResolved = resolveToRealPath(normalizedPlanPath);
if (isSubpath(realPlansDir, realResolved)) {
return normalizedPlanPath;
}
} catch {
// Fall through if resolveToRealPath fails
}
}
// 3. Handle default case where agent provided a path relative to the plans directory
const resolvedPath = path.resolve(plansDir, trimmedPath);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
throw new Error(
PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir),
);
// 2. Try resolving relative to project root
const resolvedFromProjectRoot = path.resolve(projectRoot, normalizedPlanPath);
try {
const realResolved = resolveToRealPath(resolvedFromProjectRoot);
if (isSubpath(realPlansDir, realResolved)) {
return resolvedFromProjectRoot;
}
} catch {
const directResolved = path.resolve(resolvedFromProjectRoot);
if (isSubpath(realPlansDir, directResolved)) {
return resolvedFromProjectRoot;
}
}
return resolvedPath;
// 3. Try resolving relative to plansDir
const resolvedFromPlansDir = path.resolve(plansDir, normalizedPlanPath);
try {
const realResolved = resolveToRealPath(resolvedFromPlansDir);
if (isSubpath(realPlansDir, realResolved)) {
return resolvedFromPlansDir;
}
} catch {
const directResolved = path.resolve(resolvedFromPlansDir);
if (isSubpath(realPlansDir, directResolved)) {
return resolvedFromPlansDir;
}
}
// Fallback boundary check: if still not a subpath, throw PATH_ACCESS_DENIED
throw new Error(PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir));
}
/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.45.1",
"version": "0.45.0-nightly.20260521.g854f811be",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
File diff suppressed because one or more lines are too long
+3
View File
@@ -48,6 +48,9 @@ if (packageName === 'core') {
const docsSource = join(process.cwd(), '..', '..', 'docs');
const docsTarget = join(process.cwd(), 'dist', 'docs');
if (existsSync(docsSource)) {
if (existsSync(docsTarget)) {
execSync(`rm -rf "${docsTarget}"`);
}
cpSync(docsSource, docsTarget, { recursive: true, dereference: true });
console.log('Copied documentation to dist/docs');
}