Compare commits

...

5 Commits

Author SHA1 Message Date
gemini-cli-robot b545258c4c chore(release): v0.45.0-preview.0 2026-05-27 18:59:36 +00:00
Mukunda Rao Katta 5cac7c10fa fix(cli): ignore unmapped vim normal keys (#27102) 2026-05-27 17:03:00 +00:00
Om Patel 41c9260cae fix(core): prevent blacklist bypass in mcp list (#27377)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-05-26 22:08:37 +00:00
Tommaso Sciortino 8b56d27901 fix(core): suppress PTY resize EBADF errors (#27461) 2026-05-26 19:43:51 +00:00
Daniel Weis 85563dabe8 fix(core): bypass routing classifiers to prevent orphaned function response errors (#27389) 2026-05-26 16:19:41 +00:00
24 changed files with 676 additions and 39 deletions
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"workspaces": [
"packages/*"
],
@@ -18117,7 +18117,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18394,7 +18394,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18689,7 +18689,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-preview.0"
},
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+13 -9
View File
@@ -17,17 +17,21 @@ import {
// --- Global Entry Point ---
// Suppress known race condition error in node-pty on Windows
// Suppress known race condition error in node-pty on Windows and Linux
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
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;
if (error instanceof Error) {
const isPtyResizeError =
error.message === 'Cannot resize a pty that has already exited';
const isEbadfError = error.message.includes('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;
}
}
// 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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-preview.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+254
View File
@@ -475,4 +475,258 @@ 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();
});
});
+12 -2
View File
@@ -159,12 +159,16 @@ 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: activeSettings.mcp?.allowed,
excludedList: activeSettings.mcp?.excluded,
allowedList: consolidatedAllowed,
excludedList:
consolidatedExcluded.length > 0 ? consolidatedExcluded : undefined,
enablement: mcpEnablementManager.getEnablementCallbacks(),
});
@@ -227,6 +231,10 @@ export async function listMcpServers(
);
}
const consolidatedExcluded =
loadedSettings.getConsolidatedExcludedMcpServers();
const consolidatedAllowed = loadedSettings.getConsolidatedAllowedMcpServers();
debugLogger.log('Configured MCP servers:\n');
for (const serverName of serverNames) {
@@ -237,6 +245,8 @@ export async function listMcpServers(
server,
loadedSettings.isTrusted,
activeSettings,
consolidatedExcluded,
consolidatedAllowed,
);
let statusIndicator = '';
+14 -3
View File
@@ -576,6 +576,7 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
loadedSettings?: LoadedSettings;
}
export async function loadCliConfig(
@@ -584,7 +585,12 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
const {
cwd = process.cwd(),
projectHooks,
skipExtensions = false,
loadedSettings,
} = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -985,12 +991,17 @@ export async function loadCliConfig(
agents: settings.agents,
adminSkillsEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
? (argv.allowedMcpServerNames ??
(loadedSettings
? loadedSettings.getConsolidatedAllowedMcpServers()
: settings.mcp?.allowed))
: undefined,
blockedMcpServers: mcpEnabled
? argv.allowedMcpServerNames
? undefined
: settings.mcp?.excluded
: loadedSettings
? loadedSettings.getConsolidatedExcludedMcpServers()
: settings.mcp?.excluded
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
@@ -119,7 +119,7 @@ export async function canLoadServer(
}
// 2. Allowlist check
if (config.allowedList && config.allowedList.length > 0) {
if (config.allowedList !== undefined) {
const { found, deprecationWarning } = isInSettingsList(
normalizedId,
config.allowedList,
+71
View File
@@ -1109,6 +1109,77 @@ 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,6 +509,51 @@ 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,6 +499,7 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
skipExtensions: true,
loadedSettings: settings,
});
adminControlsListner.setConfig(partialConfig);
@@ -627,6 +628,7 @@ export async function main() {
config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
loadedSettings: settings,
});
loadConfigHandle?.end();
@@ -81,4 +81,24 @@ 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();
},
);
});
+8 -2
View File
@@ -1486,8 +1486,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });
// Ignore any Insertable key in Normal Mode
if (normalizedKey.insertable) {
// 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
) {
return true;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -386,6 +386,97 @@ 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);
@@ -145,12 +145,22 @@ 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),
);
@@ -475,6 +475,105 @@ 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)' }] },
@@ -142,6 +142,19 @@ 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 };
@@ -1517,12 +1517,13 @@ export class ShellExecutionService {
// 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 || isWindowsPtyError) {
// On Unix, we get an ESRCH error.
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 {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.45.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"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.0-nightly.20260521.g854f811be",
"version": "0.45.0-preview.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {