feat(acp): support set_mode interface (#18890) (#18891)

Co-authored-by: Mervap <megavaprold@gmail.com>
This commit is contained in:
Valery Teplyakov
2026-02-19 17:07:46 +01:00
committed by GitHub
parent b79e5ce56d
commit 966eef14ee
3 changed files with 184 additions and 2 deletions
@@ -16,6 +16,7 @@ import {
import { GeminiAgent } from './zedIntegration.js'; import { GeminiAgent } from './zedIntegration.js';
import * as acp from '@agentclientprotocol/sdk'; import * as acp from '@agentclientprotocol/sdk';
import { import {
ApprovalMode,
AuthType, AuthType,
type Config, type Config,
CoreToolCallStatus, CoreToolCallStatus,
@@ -62,6 +63,8 @@ describe('GeminiAgent Session Resume', () => {
storage: { storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
}, },
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>; } as unknown as Mocked<Config>;
mockSettings = { mockSettings = {
merged: { merged: {
@@ -149,7 +152,28 @@ describe('GeminiAgent Session Resume', () => {
mcpServers: [], 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 // Verify resumeChat received the correct arguments
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith( expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
@@ -35,6 +35,7 @@ import {
import { loadCliConfig, type CliArgs } from '../config/config.js'; import { loadCliConfig, type CliArgs } from '../config/config.js';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js';
vi.mock('../config/config.js', () => ({ vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(), loadCliConfig: vi.fn(),
@@ -119,6 +120,8 @@ describe('GeminiAgent', () => {
subscribe: vi.fn(), subscribe: vi.fn(),
unsubscribe: vi.fn(), unsubscribe: vi.fn(),
}), }),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>; } as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = { mockSettings = {
merged: { merged: {
@@ -185,6 +188,59 @@ describe('GeminiAgent', () => {
expect(mockConfig.getGeminiClient).toHaveBeenCalled(); 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 () => { it('should fail session creation if Gemini API key is missing', async () => {
(loadSettings as unknown as Mock).mockImplementation(() => ({ (loadSettings as unknown as Mock).mockImplementation(() => ({
merged: { merged: {
@@ -306,6 +362,32 @@ describe('GeminiAgent', () => {
expect(session.prompt).toHaveBeenCalled(); expect(session.prompt).toHaveBeenCalled();
expect(result).toEqual({ stopReason: 'end_turn' }); 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<string, Session> }
).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', () => { describe('Session', () => {
@@ -352,6 +434,8 @@ describe('Session', () => {
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
getDebugMode: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus), getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>; } as unknown as Mocked<Config>;
mockConnection = { mockConnection = {
sessionUpdate: vi.fn(), sessionUpdate: vi.fn(),
@@ -822,4 +906,17 @@ describe('Session', () => {
].value; ].value;
expect(mockInstance.build).toHaveBeenCalled(); 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',
);
});
}); });
@@ -36,6 +36,7 @@ import {
Kind, Kind,
partListUnionToString, partListUnionToString,
LlmRole, LlmRole,
ApprovalMode,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk'; import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js'; import { AcpFileSystemService } from './fileSystemService.js';
@@ -225,6 +226,10 @@ export class GeminiAgent {
return { return {
sessionId, 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages); session.streamHistory(sessionData.messages);
return {}; return {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
};
} }
private async initializeSessionConfig( private async initializeSessionConfig(
@@ -377,6 +387,16 @@ export class GeminiAgent {
} }
return session.prompt(params); return session.prompt(params);
} }
async setSessionMode(
params: acp.SetSessionModeRequest,
): Promise<acp.SetSessionModeResponse> {
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 { export class Session {
@@ -398,6 +418,17 @@ export class Session {
this.pendingPrompt = null; 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<void> { async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) { for (const msg of messages) {
const contentString = partListUnionToString(msg.content); const contentString = partListUnionToString(msg.content);
@@ -1273,3 +1304,33 @@ function toAcpToolKind(kind: Kind): acp.ToolKind {
return 'other'; 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;
}