diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/zed-integration/acpResume.test.ts index d6e12aec25..869c27bf52 100644 --- a/packages/cli/src/zed-integration/acpResume.test.ts +++ b/packages/cli/src/zed-integration/acpResume.test.ts @@ -16,6 +16,7 @@ import { import { GeminiAgent } from './zedIntegration.js'; import * as acp from '@agentclientprotocol/sdk'; import { + ApprovalMode, AuthType, type Config, CoreToolCallStatus, @@ -62,6 +63,8 @@ describe('GeminiAgent Session Resume', () => { storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, + getApprovalMode: vi.fn().mockReturnValue('default'), + isPlanEnabled: vi.fn().mockReturnValue(false), } as unknown as Mocked; mockSettings = { merged: { @@ -149,7 +152,28 @@ describe('GeminiAgent Session Resume', () => { mcpServers: [], }); - expect(response).toEqual({}); + expect(response).toEqual({ + modes: { + availableModes: [ + { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Prompts for approval', + }, + { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Auto-approves all tools', + }, + ], + currentModeId: ApprovalMode.DEFAULT, + }, + }); // Verify resumeChat received the correct arguments expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith( diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts index edc32f04b6..cc71dd9309 100644 --- a/packages/cli/src/zed-integration/zedIntegration.test.ts +++ b/packages/cli/src/zed-integration/zedIntegration.test.ts @@ -35,6 +35,7 @@ import { import { loadCliConfig, type CliArgs } from '../config/config.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js'; vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), @@ -119,6 +120,8 @@ describe('GeminiAgent', () => { subscribe: vi.fn(), unsubscribe: vi.fn(), }), + getApprovalMode: vi.fn().mockReturnValue('default'), + isPlanEnabled: vi.fn().mockReturnValue(false), } as unknown as Mocked>>; mockSettings = { merged: { @@ -185,6 +188,59 @@ describe('GeminiAgent', () => { expect(mockConfig.getGeminiClient).toHaveBeenCalled(); }); + it('should return modes without plan mode when plan is disabled', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false); + mockConfig.getApprovalMode = vi.fn().mockReturnValue('default'); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.modes).toEqual({ + availableModes: [ + { id: 'default', name: 'Default', description: 'Prompts for approval' }, + { + id: 'autoEdit', + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, + ], + currentModeId: 'default', + }); + }); + + it('should return modes with plan mode when plan is enabled', async () => { + mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ + apiKey: 'test-key', + }); + mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true); + mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan'); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.modes).toEqual({ + availableModes: [ + { id: 'default', name: 'Default', description: 'Prompts for approval' }, + { + id: 'autoEdit', + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' }, + { id: 'plan', name: 'Plan', description: 'Read-only mode' }, + ], + currentModeId: 'plan', + }); + }); + it('should fail session creation if Gemini API key is missing', async () => { (loadSettings as unknown as Mock).mockImplementation(() => ({ merged: { @@ -306,6 +362,32 @@ describe('GeminiAgent', () => { expect(session.prompt).toHaveBeenCalled(); expect(result).toEqual({ stopReason: 'end_turn' }); }); + + it('should delegate setMode to session', async () => { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const session = ( + agent as unknown as { sessions: Map } + ).sessions.get('test-session-id'); + if (!session) throw new Error('Session not found'); + session.setMode = vi.fn().mockReturnValue({}); + + const result = await agent.setSessionMode({ + sessionId: 'test-session-id', + modeId: 'plan', + }); + + expect(session.setMode).toHaveBeenCalledWith('plan'); + expect(result).toEqual({}); + }); + + it('should throw error when setting mode on non-existent session', async () => { + await expect( + agent.setSessionMode({ + sessionId: 'unknown', + modeId: 'plan', + }), + ).rejects.toThrow('Session not found: unknown'); + }); }); describe('Session', () => { @@ -352,6 +434,8 @@ describe('Session', () => { getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(mockMessageBus), + setApprovalMode: vi.fn(), + isPlanEnabled: vi.fn().mockReturnValue(false), } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), @@ -822,4 +906,17 @@ describe('Session', () => { ].value; expect(mockInstance.build).toHaveBeenCalled(); }); + + it('should set mode on config', () => { + session.setMode(ApprovalMode.AUTO_EDIT); + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + }); + + it('should throw error for invalid mode', () => { + expect(() => session.setMode('invalid-mode')).toThrow( + 'Invalid or unavailable mode: invalid-mode', + ); + }); }); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 61c255a21c..44b1890ce2 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -36,6 +36,7 @@ import { Kind, partListUnionToString, LlmRole, + ApprovalMode, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -225,6 +226,10 @@ export class GeminiAgent { return { sessionId, + modes: { + availableModes: buildAvailableModes(config.isPlanEnabled()), + currentModeId: config.getApprovalMode(), + }, }; } @@ -276,7 +281,12 @@ export class GeminiAgent { // eslint-disable-next-line @typescript-eslint/no-floating-promises session.streamHistory(sessionData.messages); - return {}; + return { + modes: { + availableModes: buildAvailableModes(config.isPlanEnabled()), + currentModeId: config.getApprovalMode(), + }, + }; } private async initializeSessionConfig( @@ -377,6 +387,16 @@ export class GeminiAgent { } return session.prompt(params); } + + async setSessionMode( + params: acp.SetSessionModeRequest, + ): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + return session.setMode(params.modeId); + } } export class Session { @@ -398,6 +418,17 @@ export class Session { this.pendingPrompt = null; } + setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { + const availableModes = buildAvailableModes(this.config.isPlanEnabled()); + const mode = availableModes.find((m) => m.id === modeId); + if (!mode) { + throw new Error(`Invalid or unavailable mode: ${modeId}`); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.config.setApprovalMode(mode.id as ApprovalMode); + return {}; + } + async streamHistory(messages: ConversationRecord['messages']): Promise { for (const msg of messages) { const contentString = partListUnionToString(msg.content); @@ -1273,3 +1304,33 @@ function toAcpToolKind(kind: Kind): acp.ToolKind { return 'other'; } } + +function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] { + const modes: acp.SessionMode[] = [ + { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Prompts for approval', + }, + { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Auto-approves edit tools', + }, + { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Auto-approves all tools', + }, + ]; + + if (isPlanEnabled) { + modes.push({ + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Read-only mode', + }); + } + + return modes; +}