Compare commits

..

5 Commits

Author SHA1 Message Date
cynthialong0-0 96e5fc1663 Merge branch 'main' into fix/setup-github-command 2026-02-25 07:26:44 -08:00
Cynthia Long 3815de798f sort the workflow paths as suggested by bot 2026-02-25 15:11:07 +00:00
Shreya Keshive 50947c57ce fix(acp): update auth handshake to spec (#19725) 2026-02-25 15:04:42 +00:00
Cynthia Long ac3c643035 Fix: update SetupGithubCommand for plan-execute 2026-02-25 15:00:30 +00:00
Adib234 29e8f2abf4 feat(plan): add integration tests for plan mode (#20214) 2026-02-25 04:56:16 +00:00
9 changed files with 225 additions and 21 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should allow read-only tools but deny write tools in plan mode', async () => {
await rig.setup(
'should allow read-only tools but deny write tools in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: [
'run_shell_command',
'list_directory',
'write_file',
'read_file',
],
},
},
},
);
// We use a prompt that asks for both a read-only action and a write action.
// "List files" (read-only) followed by "touch denied.txt" (write).
const result = await rig.run({
approvalMode: 'plan',
stdin:
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
});
const lsCallFound = await rig.waitForToolCall('list_directory');
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
expect(
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
expect(lsLog?.toolRequest.success).toBe(true);
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
testName: 'Plan Mode restrictions test',
});
});
it('should allow write_file only in the plans directory in plan mode', async () => {
await rig.setup(
'should allow write_file only in the plans directory in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
allowed: ['write_file'],
},
general: { defaultApprovalMode: 'plan' },
},
},
);
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
// Verify that write_file outside of plan directory fails
await rig.run({
approvalMode: 'plan',
stdin:
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
const toolLogs = rig.readToolLogs();
const writeLogs = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
);
const planWrite = writeLogs.find(
(l) =>
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
const blockedWrite = writeLogs.find((l) =>
l.toolRequest.args.includes('hello.txt'),
);
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
if (blockedWrite) {
expect(blockedWrite?.toolRequest.success).toBe(false);
}
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should be able to enter plan mode from default mode', async () => {
await rig.setup('should be able to enter plan mode from default mode', {
settings: {
experimental: { plan: true },
tools: {
core: ['enter_plan_mode'],
allowed: ['enter_plan_mode'],
},
},
});
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
stdin:
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall(
'enter_plan_mode',
10000,
);
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode',
);
expect(enterLog?.toolRequest.success).toBe(true);
});
});
@@ -23,15 +23,17 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { debugLogger } from '@google/gemini-cli-core';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'gemini-assistant/gemini-plan-execute.yml',
'gemini-dispatch/gemini-dispatch.yml',
'issue-triage/gemini-scheduled-triage.yml',
'issue-triage/gemini-triage.yml',
'pr-review/gemini-review.yml',
];
export const GITHUB_COMMANDS_PATHS = [
'gemini-assistant/gemini-invoke.toml',
'gemini-assistant/gemini-plan-execute.toml',
'issue-triage/gemini-scheduled-triage.toml',
'issue-triage/gemini-triage.toml',
'pr-review/gemini-review.toml',
@@ -177,6 +177,14 @@ describe('GeminiAgent', () => {
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
expect(geminiAuth?._meta).toEqual({
'api-key': {
provider: 'google',
},
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
@@ -187,6 +195,7 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -195,6 +204,25 @@ describe('GeminiAgent', () => {
);
});
it('should authenticate correctly with api-key in _meta', async () => {
await agent.authenticate({
methodId: AuthType.USE_GEMINI,
_meta: {
'api-key': 'test-api-key',
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.USE_GEMINI,
);
});
it('should create a new session', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
@@ -37,12 +37,17 @@ import {
partListUnionToString,
LlmRole,
ApprovalMode,
getVersion,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { Readable, Writable } from 'node:stream';
function hasMeta(obj: unknown): obj is { _meta?: Record<string, unknown> } {
return typeof obj === 'object' && obj !== null && '_meta' in obj;
}
import type { Content, Part, FunctionCall } from '@google/genai';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope, loadSettings } from '../config/settings.js';
@@ -81,6 +86,7 @@ export async function runZedIntegration(
export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
constructor(
private config: Config,
@@ -97,25 +103,35 @@ export class GeminiAgent {
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
description: null,
description: 'Log in with your Google account',
},
{
id: AuthType.USE_GEMINI,
name: 'Use Gemini API key',
description:
'Requires setting the `GEMINI_API_KEY` environment variable',
name: 'Gemini API key',
description: 'Use an API key with Gemini Developer API',
_meta: {
'api-key': {
provider: 'google',
},
},
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
description: null,
description: 'Use an API key with Vertex AI GenAI API',
},
];
await this.config.initialize();
const version = await getVersion();
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentInfo: {
name: 'gemini-cli',
title: 'Gemini CLI',
version,
},
agentCapabilities: {
loadSession: true,
promptCapabilities: {
@@ -131,7 +147,8 @@ export class GeminiAgent {
};
}
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
const { methodId } = req;
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security.auth.selectedType;
@@ -139,17 +156,21 @@ export class GeminiAgent {
if (selectedAuthType && selectedAuthType !== method) {
await clearCachedCredentialFile();
}
// Check for api-key in _meta
const meta = hasMeta(req) ? req._meta : undefined;
const apiKey =
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
// or perform new authentication if needed
try {
await this.config.refreshAuth(method);
if (apiKey) {
this.apiKey = apiKey;
}
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
} catch (e) {
throw new acp.RequestError(
getErrorStatus(e) || 401,
getAcpErrorMessage(e),
);
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
this.settings.setValue(
SettingScope.User,
@@ -177,7 +198,7 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(authType);
await config.refreshAuth(authType, this.apiKey);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -199,7 +220,7 @@ export class GeminiAgent {
if (!isAuthenticated) {
throw new acp.RequestError(
401,
-32000,
authErrorMessage || 'Authentication required.',
);
}
@@ -302,7 +323,7 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType);
await config.refreshAuth(selectedAuthType, this.apiKey);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -95,6 +95,7 @@ const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
getExperimentalZedIntegration: () => false,
} as unknown as Config;
// Mock fetch globally
+6 -3
View File
@@ -271,9 +271,12 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
} else {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
// In Zed integration, we skip the interactive consent and directly open the browser
if (!config.getExperimentalZedIntegration()) {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
}
}
const webLogin = await authWithWeb(client);
+1
View File
@@ -499,6 +499,7 @@ describe('Server Config (config.ts)', () => {
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
config,
authType,
undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
+2 -1
View File
@@ -1126,7 +1126,7 @@ export class Config {
return this.contentGenerator;
}
async refreshAuth(authMethod: AuthType) {
async refreshAuth(authMethod: AuthType, apiKey?: string) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -1152,6 +1152,7 @@ export class Config {
const newContentGeneratorConfig = await createContentGeneratorConfig(
this,
authMethod,
apiKey,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
+5 -1
View File
@@ -90,9 +90,13 @@ export type ContentGeneratorConfig = {
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
apiKey?: string,
): Promise<ContentGeneratorConfig> {
const geminiApiKey =
process.env['GEMINI_API_KEY'] || (await loadApiKey()) || undefined;
apiKey ||
process.env['GEMINI_API_KEY'] ||
(await loadApiKey()) ||
undefined;
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
const googleCloudProject =
process.env['GOOGLE_CLOUD_PROJECT'] ||