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

View File

@@ -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<Config>;
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(

View File

@@ -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<Awaited<ReturnType<typeof loadCliConfig>>>;
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<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', () => {
@@ -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<Config>;
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',
);
});
});

View File

@@ -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<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 {
@@ -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<void> {
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;
}