From 13a44cf8b586cad10193a3e104f24aed35496c28 Mon Sep 17 00:00:00 2001 From: Benjamin Western Date: Sat, 4 Apr 2026 13:59:14 +1100 Subject: [PATCH 1/3] feat(cli): add ACP host input requests --- docs/cli/acp-mode.md | 54 ++- docs/tools/ask-user.md | 68 +++ packages/cli/src/acp/acpClient.test.ts | 567 ++++++++++++++++++++++++- packages/cli/src/acp/acpClient.ts | 243 +++++++++-- packages/cli/src/acp/hostInput.ts | 165 +++++++ packages/cli/src/config/config.test.ts | 13 + packages/cli/src/config/config.ts | 16 +- 7 files changed, 1073 insertions(+), 53 deletions(-) create mode 100644 packages/cli/src/acp/hostInput.ts diff --git a/docs/cli/acp-mode.md b/docs/cli/acp-mode.md index 16ff3b9a157..fb46cf5d97f 100644 --- a/docs/cli/acp-mode.md +++ b/docs/cli/acp-mode.md @@ -52,6 +52,58 @@ and Gemini CLI (the server). The core of the ACP implementation can be found in `packages/cli/src/acp/acpClient.ts`. +## Host input extensions + +ACP covers prompts, cancellations, permissions, and session control. Gemini CLI +also exposes a Gemini-specific ACP extension for structured host input so an ACP +client can surface questions in its own UI instead of handing control back to +the terminal. + +During `initialize`, Gemini CLI advertises the extension in +`agentCapabilities._meta.geminiCli.hostInput`: + +```json +{ + "version": 1, + "requestUserInput": true, + "method": "gemini/requestUserInput", + "supportedKinds": ["ask_user", "exit_plan_mode"] +} +``` + +To enable host input, the ACP client must advertise +`clientCapabilities._meta.geminiCli.hostInput` with `requestUserInput: true`. +The client can also narrow support by setting `supportedKinds`. + +- Include `ask_user` to let Gemini CLI surface `ask_user` tool requests over + ACP. +- Include `exit_plan_mode` to let Gemini CLI surface plan-approval questions + over ACP. +- Omit `ask_user` if you want to keep `ask_user` disabled in ACP mode while + still supporting other host-input request kinds. + +If the client doesn't opt in, Gemini CLI keeps `ask_user` excluded in ACP mode. +This preserves the default ACP behavior and avoids opening host-driven dialogs +unless the client has explicitly implemented them. + +When Gemini CLI needs host input, it calls `gemini/requestUserInput` as an ACP +extension request. The request includes: + +- `sessionId` for the active ACP session +- `requestId` for the host-input interaction +- `kind`, such as `ask_user` or `exit_plan_mode` +- `title` and `questions` +- optional `extraParts` +- optional `toolCall` metadata + +The client responds with either: + +- `{"outcome":"submitted","answers":{"0":"..."}}` +- `{"outcome":"cancelled"}` + +This extension is separate from MCP. Use it when you want Gemini CLI to keep +owning the tool flow while your ACP host owns the user-facing input surface. + ### Extending with MCP ACP can be used with the Model Context Protocol (MCP). This lets an ACP client @@ -78,7 +130,7 @@ control Gemini CLI. ### Core methods - `initialize`: Establishes the initial connection and lets the client to - register its MCP server. + register its MCP server and negotiate Gemini-specific ACP extensions. - `authenticate`: Authenticates the user. - `newSession`: Starts a new chat session. - `loadSession`: Loads a previous session. diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md index 14770b4c993..1e66d6f7894 100644 --- a/docs/tools/ask-user.md +++ b/docs/tools/ask-user.md @@ -33,6 +33,8 @@ confirmation. - Presents an interactive dialog to the user with the specified questions. - Pauses execution until the user provides answers or dismisses the dialog. - Returns the user's answers to the model. + - In ACP mode, Gemini CLI keeps `ask_user` disabled unless the ACP client + explicitly opts in to Gemini CLI host-input requests. - **Output (`llmContent`):** A JSON string containing the user's answers, indexed by question position (e.g., @@ -40,6 +42,72 @@ confirmation. - **Confirmation:** Yes. The tool inherently involves user interaction. +## ACP mode + +In ACP mode, Gemini CLI doesn't assume that the host client can handle +interactive user questions. To preserve existing ACP behavior, Gemini CLI +excludes `ask_user` unless the host explicitly advertises support. + +To enable `ask_user` over ACP, the host client must do all of the following: + +1. Set `clientCapabilities._meta.geminiCli.hostInput.requestUserInput` to + `true`. +2. Include `ask_user` in + `clientCapabilities._meta.geminiCli.hostInput.supportedKinds`, or omit + `supportedKinds` entirely. +3. Handle the `gemini/requestUserInput` ACP extension request and return either + submitted answers or cancellation. + +If the host omits `ask_user` from `supportedKinds`, Gemini CLI keeps the tool +disabled in ACP mode. This lets a client support other host-input request kinds +without taking on `ask_user`. + +When enabled, Gemini CLI sends the same question payload that the terminal UI +uses. The ACP extension request looks like this: + +```json +{ + "sessionId": "session-123", + "requestId": "ask_user-456", + "kind": "ask_user", + "title": "Ask User", + "questions": [ + { + "header": "Database", + "question": "Which database would you like to use?", + "type": "choice", + "options": [ + { + "label": "PostgreSQL", + "description": "Powerful, open source object-relational database system." + }, + { + "label": "SQLite", + "description": "C-library that implements a SQL database engine." + } + ] + } + ] +} +``` + +The ACP client responds with one of these payloads: + +```json +{ + "outcome": "submitted", + "answers": { + "0": "PostgreSQL" + } +} +``` + +```json +{ + "outcome": "cancelled" +} +``` + ## Usage Examples ### Multiple Choice Question diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 470ff38351f..1d398f61c61 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -40,6 +40,11 @@ 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'; +import { + ACP_HOST_INPUT_CAPABILITY_KEY, + ACP_HOST_INPUT_REQUEST_METHOD, + PLAN_APPROVAL_AUTO_OPTION, +} from './hostInput.js'; vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn(), @@ -247,6 +252,14 @@ describe('GeminiAgent', () => { }, }); expect(response.agentCapabilities?.loadSession).toBe(true); + expect(response.agentCapabilities?._meta).toEqual( + expect.objectContaining({ + [ACP_HOST_INPUT_CAPABILITY_KEY]: expect.objectContaining({ + requestUserInput: true, + method: ACP_HOST_INPUT_REQUEST_METHOD, + }), + }), + ); }); it('should authenticate correctly', async () => { @@ -517,7 +530,68 @@ describe('GeminiAgent', () => { }), 'test-session-id', mockArgv, - { cwd: '/tmp' }, + { cwd: '/tmp', acpAskUserEnabled: false }, + ); + }); + + it('should enable ACP host input in config when the client advertises support', async () => { + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); + await agent.initialize({ + clientCapabilities: { + _meta: { + [ACP_HOST_INPUT_CAPABILITY_KEY]: { + requestUserInput: true, + version: 1, + }, + }, + }, + protocolVersion: 1, + }); + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(loadCliConfig).toHaveBeenCalledWith( + expect.anything(), + 'test-session-id', + mockArgv, + expect.objectContaining({ + cwd: '/tmp', + acpAskUserEnabled: true, + }), + ); + }); + + it('should keep ask_user disabled in config when host input excludes ask_user', async () => { + agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection); + await agent.initialize({ + clientCapabilities: { + _meta: { + [ACP_HOST_INPUT_CAPABILITY_KEY]: { + requestUserInput: true, + supportedKinds: ['exit_plan_mode'], + version: 1, + }, + }, + }, + protocolVersion: 1, + }); + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(loadCliConfig).toHaveBeenCalledWith( + expect.anything(), + 'test-session-id', + mockArgv, + expect.objectContaining({ + cwd: '/tmp', + acpAskUserEnabled: false, + }), ); }); @@ -714,6 +788,7 @@ describe('Session', () => { mockConnection = { sessionUpdate: vi.fn(), requestPermission: vi.fn(), + extMethod: vi.fn(), sendNotification: vi.fn(), } as unknown as Mocked; @@ -1109,6 +1184,496 @@ describe('Session', () => { ); }); + it('should request host input for ask_user confirmations when enabled', async () => { + const confirmationDetails = { + type: 'ask_user' as const, + title: 'Ask User', + questions: [ + { + header: 'Mode', + question: 'Which mode should we use?', + type: 'choice', + options: [ + { + label: 'Auto Edit', + description: 'Automatically approve edit tools', + }, + { + label: 'Default', + description: 'Require approval for edits', + }, + ], + }, + ], + onConfirm: vi.fn(), + }; + const hostInputSession = new Session( + 'session-1', + mockChat, + mockConfig, + mockConnection, + { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings, + { askUser: true, exitPlanMode: true }, + ); + + mockTool.build.mockReturnValue({ + getDescription: () => 'Ask User', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.extMethod.mockResolvedValue({ + outcome: 'submitted', + answers: { '0': 'Auto Edit' }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await hostInputSession.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Ask the user something' }], + }); + + expect(mockConnection.extMethod).toHaveBeenCalledWith( + ACP_HOST_INPUT_REQUEST_METHOD, + expect.objectContaining({ + sessionId: 'session-1', + kind: 'ask_user', + questions: confirmationDetails.questions, + }), + ); + expect(mockConnection.requestPermission).not.toHaveBeenCalled(); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'Auto Edit' } }, + ); + }); + + it('should cancel ask_user confirmations when host input is cancelled', async () => { + const confirmationDetails = { + type: 'ask_user' as const, + title: 'Ask User', + questions: [ + { + header: 'Mode', + question: 'Which mode should we use?', + type: 'choice', + options: [ + { + label: 'Auto Edit', + description: 'Automatically approve edit tools', + }, + { + label: 'Default', + description: 'Require approval for edits', + }, + ], + }, + ], + onConfirm: vi.fn(), + }; + const hostInputSession = new Session( + 'session-1', + mockChat, + mockConfig, + mockConnection, + { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings, + { askUser: true, exitPlanMode: true }, + ); + + mockTool.build.mockReturnValue({ + getDescription: () => 'Ask User', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.extMethod.mockResolvedValue({ + outcome: 'cancelled', + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await hostInputSession.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Ask the user something' }], + }); + + expect(mockConnection.extMethod).toHaveBeenCalledWith( + ACP_HOST_INPUT_REQUEST_METHOD, + expect.objectContaining({ + sessionId: 'session-1', + kind: 'ask_user', + }), + ); + expect(mockConnection.requestPermission).not.toHaveBeenCalled(); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.Cancel, + ); + }); + + it('should cancel ask_user confirmations when host input support is disabled', async () => { + const confirmationDetails = { + type: 'ask_user' as const, + title: 'Ask User', + questions: [ + { + header: 'Mode', + question: 'Which mode should we use?', + type: 'choice', + options: [ + { + label: 'Auto Edit', + description: 'Automatically approve edit tools', + }, + { + label: 'Default', + description: 'Require approval for edits', + }, + ], + }, + ], + onConfirm: vi.fn(), + }; + const hostInputSession = new Session( + 'session-1', + mockChat, + mockConfig, + mockConnection, + { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings, + { askUser: false, exitPlanMode: true }, + ); + + mockTool.build.mockReturnValue({ + getDescription: () => 'Ask User', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await hostInputSession.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Ask the user something' }], + }); + + expect(mockConnection.extMethod).not.toHaveBeenCalled(); + expect(mockConnection.requestPermission).not.toHaveBeenCalled(); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.Cancel, + ); + }); + + it('should request host input for exit_plan_mode when enabled', async () => { + const planPath = '/tmp/approved-plan.md'; + const confirmationDetails = { + type: 'exit_plan_mode' as const, + title: 'Plan Approval', + planPath, + onConfirm: vi.fn(), + }; + const hostInputSession = new Session( + 'session-1', + mockChat, + mockConfig, + mockConnection, + { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings, + { askUser: true, exitPlanMode: true }, + ); + + (fs.readFile as unknown as Mock).mockResolvedValue('# Approved plan'); + mockTool.build.mockReturnValue({ + getDescription: () => 'Exit plan mode', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.extMethod.mockResolvedValue({ + outcome: 'submitted', + answers: { '0': PLAN_APPROVAL_AUTO_OPTION }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await hostInputSession.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Approve the plan' }], + }); + + expect(mockConnection.extMethod).toHaveBeenCalledWith( + ACP_HOST_INPUT_REQUEST_METHOD, + expect.objectContaining({ + sessionId: 'session-1', + kind: 'exit_plan_mode', + extraParts: [`Plan file: ${planPath}`], + }), + ); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { + approved: true, + approvalMode: ApprovalMode.AUTO_EDIT, + }, + ); + }); + + it('should fall back to requestPermission for exit_plan_mode when host input is disabled', async () => { + const planPath = '/tmp/approved-plan.md'; + const confirmationDetails = { + type: 'exit_plan_mode' as const, + title: 'Plan Approval', + planPath, + onConfirm: vi.fn(), + }; + const hostInputSession = new Session( + 'session-1', + mockChat, + mockConfig, + mockConnection, + { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings, + { askUser: true, exitPlanMode: false }, + ); + + mockTool.build.mockReturnValue({ + getDescription: () => 'Exit plan mode', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await hostInputSession.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Approve the plan' }], + }); + + expect(mockConnection.extMethod).not.toHaveBeenCalled(); + expect(mockConnection.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + toolCall: expect.objectContaining({ + title: 'Exit plan mode', + }), + }), + ); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + }); + + it('should cancel exit_plan_mode when the plan file is missing', async () => { + const planPath = '/tmp/missing-plan.md'; + const confirmationDetails = { + type: 'exit_plan_mode' as const, + title: 'Plan Approval', + planPath, + onConfirm: vi.fn(), + }; + const hostInputSession = new Session( + 'session-1', + mockChat, + mockConfig, + mockConnection, + { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { + security: { enablePermanentToolApproval: true }, + mcpServers: {}, + }, + errors: [], + } as unknown as LoadedSettings, + { askUser: true, exitPlanMode: true }, + ); + + (fs.readFile as unknown as Mock).mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); + mockTool.build.mockReturnValue({ + getDescription: () => 'Exit plan mode', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await hostInputSession.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Approve the plan' }], + }); + + expect(mockConnection.extMethod).not.toHaveBeenCalled(); + expect(confirmationDetails.onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.Cancel, + ); + }); + it('should exclude always allow options when disableAlwaysAllow is true', async () => { mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true); const confirmationDetails = { diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index e0a352e0d14..6a6b72dc4aa 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -78,6 +78,14 @@ import { runExitCleanup } from '../utils/cleanup.js'; import { SessionSelector } from '../utils/sessionUtils.js'; import { CommandHandler } from './commandHandler.js'; +import { + ACP_HOST_INPUT_CAPABILITY_KEY, + buildExitPlanModeQuestions, + buildHostInputAgentCapability, + mapExitPlanModeAnswers, + requestHostInput, + resolveHostInputSupport, +} from './hostInput.js'; const RequestPermissionResponseSchema = z.object({ outcome: z.discriminatedUnion('outcome', [ @@ -183,6 +191,9 @@ export class GeminiAgent { version, }, agentCapabilities: { + _meta: { + [ACP_HOST_INPUT_CAPABILITY_KEY]: buildHostInputAgentCapability(), + }, loadSession: true, promptCapabilities: { image: true, @@ -327,6 +338,7 @@ export class GeminiAgent { const geminiClient = config.getGeminiClient(); const chat = await geminiClient.startChat(); + const hostInputSupport = resolveHostInputSupport(this.clientCapabilities); const session = new Session( sessionId, @@ -334,6 +346,7 @@ export class GeminiAgent { config, this.connection, this.settings, + hostInputSupport, ); this.sessions.set(sessionId, session); @@ -391,6 +404,7 @@ export class GeminiAgent { config, this.connection, this.settings, + resolveHostInputSupport(this.clientCapabilities), ); this.sessions.set(sessionId, session); @@ -516,7 +530,11 @@ export class GeminiAgent { mcpServers: mergedMcpServers, }; - const config = await loadCliConfig(settings, sessionId, this.argv, { cwd }); + const config = await loadCliConfig(settings, sessionId, this.argv, { + cwd, + acpAskUserEnabled: resolveHostInputSupport(this.clientCapabilities) + .askUser, + }); createPolicyUpdater( config.getPolicyEngine(), @@ -574,6 +592,10 @@ export class Session { private readonly context: AgentLoopContext, private readonly connection: acp.AgentSideConnection, private readonly settings: LoadedSettings, + private readonly hostInputSupport = { + askUser: false, + exitPlanMode: false, + }, ) {} async cancelPendingPrompt(): Promise { @@ -964,6 +986,105 @@ export class Session { await this.connection.sessionUpdate(params); } + private async requestAskUserInput( + callId: string, + displayTitle: string, + toolKind: acp.ToolKind, + locations: acp.ToolCallLocation[] | undefined, + confirmationDetails: Extract< + ToolCallConfirmationDetails, + { type: 'ask_user' } + >, + ): Promise { + if (!this.hostInputSupport.askUser) { + this.debug( + 'ask_user confirmation reached without ACP host input support; canceling the tool call.', + ); + await confirmationDetails.onConfirm(ToolConfirmationOutcome.Cancel); + return ToolConfirmationOutcome.Cancel; + } + + const output = await requestHostInput(this.connection, { + sessionId: this.id, + requestId: callId, + kind: 'ask_user', + title: confirmationDetails.title, + questions: confirmationDetails.questions, + toolCall: { + toolCallId: callId, + title: displayTitle, + locations, + kind: toolKind, + }, + }); + + if (output.outcome === 'cancelled') { + await confirmationDetails.onConfirm(ToolConfirmationOutcome.Cancel); + return ToolConfirmationOutcome.Cancel; + } + + await confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: output.answers, + }); + return ToolConfirmationOutcome.ProceedOnce; + } + + private async requestExitPlanModeInput( + callId: string, + displayTitle: string, + toolKind: acp.ToolKind, + locations: acp.ToolCallLocation[] | undefined, + confirmationDetails: Extract< + ToolCallConfirmationDetails, + { type: 'exit_plan_mode' } + >, + ): Promise { + let planContent: string; + try { + planContent = ( + await fs.readFile(confirmationDetails.planPath, 'utf8') + ).trim(); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + this.debug( + `Plan file ${confirmationDetails.planPath} was removed before ACP host input could be requested.`, + ); + await confirmationDetails.onConfirm(ToolConfirmationOutcome.Cancel); + return ToolConfirmationOutcome.Cancel; + } + throw error; + } + + const output = await requestHostInput(this.connection, { + sessionId: this.id, + requestId: callId, + kind: 'exit_plan_mode', + title: confirmationDetails.title, + questions: buildExitPlanModeQuestions(planContent), + extraParts: [`Plan file: ${confirmationDetails.planPath}`], + toolCall: { + toolCallId: callId, + title: displayTitle, + locations, + kind: toolKind, + }, + _meta: { + planPath: confirmationDetails.planPath, + }, + }); + + if (output.outcome === 'cancelled') { + await confirmationDetails.onConfirm(ToolConfirmationOutcome.Cancel); + return ToolConfirmationOutcome.Cancel; + } + + await confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + mapExitPlanModeAnswers(output.answers ?? {}), + ); + return ToolConfirmationOutcome.ProceedOnce; + } + private async runTool( abortSignal: AbortSignal, promptId: string, @@ -1038,6 +1159,8 @@ export class Session { const confirmationDetails = await invocation.shouldConfirmExecute(abortSignal); + const toolKind = toAcpToolKind(tool.kind); + const locations = invocation.toolLocations(); if (confirmationDetails) { const content: acp.ToolCallContent[] = []; @@ -1058,45 +1181,67 @@ export class Session { }); } - const params: acp.RequestPermissionRequest = { - sessionId: this.id, - options: toPermissionOptions( + let outcome: ToolConfirmationOutcome; + if (confirmationDetails.type === 'ask_user') { + outcome = await this.requestAskUserInput( + callId, + displayTitle, + toolKind, + locations, confirmationDetails, - this.context.config, - this.settings.merged.security.enablePermanentToolApproval, - ), - toolCall: { - toolCallId: callId, - status: 'pending', - title: displayTitle, - content, - locations: invocation.toolLocations(), - kind: toAcpToolKind(tool.kind), - }, - }; + ); + } else if ( + confirmationDetails.type === 'exit_plan_mode' && + this.hostInputSupport.exitPlanMode + ) { + outcome = await this.requestExitPlanModeInput( + callId, + displayTitle, + toolKind, + locations, + confirmationDetails, + ); + } else { + const params: acp.RequestPermissionRequest = { + sessionId: this.id, + options: toPermissionOptions( + confirmationDetails, + this.context.config, + this.settings.merged.security.enablePermanentToolApproval, + ), + toolCall: { + toolCallId: callId, + status: 'pending', + title: displayTitle, + content, + locations, + kind: toolKind, + }, + }; - const output = RequestPermissionResponseSchema.parse( - await this.connection.requestPermission(params), - ); + const output = RequestPermissionResponseSchema.parse( + await this.connection.requestPermission(params), + ); - const outcome = - output.outcome.outcome === 'cancelled' - ? ToolConfirmationOutcome.Cancel - : z - .nativeEnum(ToolConfirmationOutcome) - .parse(output.outcome.optionId); + outcome = + output.outcome.outcome === 'cancelled' + ? ToolConfirmationOutcome.Cancel + : z + .nativeEnum(ToolConfirmationOutcome) + .parse(output.outcome.optionId); - await confirmationDetails.onConfirm(outcome); + await confirmationDetails.onConfirm(outcome); - // Update policy to enable Always Allow persistence - await updatePolicy( - tool, - outcome, - confirmationDetails, - this.context, - this.context.messageBus, - invocation, - ); + // Update policy to enable Always Allow persistence + await updatePolicy( + tool, + outcome, + confirmationDetails, + this.context, + this.context.messageBus, + invocation, + ); + } switch (outcome) { case ToolConfirmationOutcome.Cancel: @@ -1111,8 +1256,10 @@ export class Session { case ToolConfirmationOutcome.ModifyWithEditor: break; default: { - const resultOutcome: never = outcome; - throw new Error(`Unexpected: ${resultOutcome}`); + const exhaustiveCheck: never = outcome; + throw new Error( + `Unhandled tool confirmation outcome: ${exhaustiveCheck}`, + ); } } } else { @@ -1124,8 +1271,8 @@ export class Session { status: 'in_progress', title: displayTitle, content, - locations: invocation.toolLocations(), - kind: toAcpToolKind(tool.kind), + locations, + kind: toolKind, }); } @@ -1140,8 +1287,8 @@ export class Session { status: 'completed', title: displayTitle, content: updateContent, - locations: invocation.toolLocations(), - kind: toAcpToolKind(tool.kind), + locations, + kind: toolKind, }); const durationMs = Date.now() - startTime; @@ -1922,7 +2069,7 @@ function toPermissionOptions( // askuser and exit_plan_mode don't need "always allow" options break; default: - // No "always allow" options for other types + // No "always allow" options for other types. break; } } @@ -1939,15 +2086,19 @@ function toPermissionOptions( case 'exit_plan_mode': case 'sandbox_expansion': break; - default: { - const unreachable: never = confirmation; - throw new Error(`Unexpected: ${unreachable}`); - } + default: + return assertUnreachableConfirmation(confirmation); } return options; } +function assertUnreachableConfirmation(value: never): never { + throw new Error( + `Unhandled tool confirmation details: ${JSON.stringify(value)}`, + ); +} + /** * Maps our internal tool kind to the ACP ToolKind. * Fallback to 'other' for kinds that are not supported by the ACP protocol. diff --git a/packages/cli/src/acp/hostInput.ts b/packages/cli/src/acp/hostInput.ts new file mode 100644 index 00000000000..6973c1b5adf --- /dev/null +++ b/packages/cli/src/acp/hostInput.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as acp from '@agentclientprotocol/sdk'; +import { + ApprovalMode, + QuestionType, + type Question, +} from '@google/gemini-cli-core'; +import { z } from 'zod'; + +export const ACP_HOST_INPUT_CAPABILITY_KEY = 'geminiCli.hostInput'; +export const ACP_HOST_INPUT_REQUEST_METHOD = 'gemini/requestUserInput'; +export const ACP_HOST_INPUT_KINDS = ['ask_user', 'exit_plan_mode'] as const; +const FIRST_QUESTION_INDEX = '0'; + +export type HostInputKind = (typeof ACP_HOST_INPUT_KINDS)[number]; +export interface HostInputSupport { + askUser: boolean; + exitPlanMode: boolean; +} + +export const PLAN_APPROVAL_AUTO_OPTION = 'Yes, automatically accept edits'; +export const PLAN_APPROVAL_MANUAL_OPTION = 'Yes, manually accept edits'; + +const HostInputCapabilitySchema = z.object({ + requestUserInput: z.boolean().optional(), + supportedKinds: z.array(z.enum(ACP_HOST_INPUT_KINDS)).optional(), + version: z.number().int().positive().optional(), +}); + +const HostInputResponseSchema = z.discriminatedUnion('outcome', [ + z.object({ + outcome: z.literal('submitted'), + answers: z.record(z.string()), + }), + z.object({ + outcome: z.literal('cancelled'), + }), +]); + +export type HostInputResponse = z.infer; + +export interface HostInputRequest { + sessionId: string; + requestId: string; + kind: 'ask_user' | 'exit_plan_mode'; + title: string; + questions: Question[]; + extraParts?: string[]; + toolCall?: { + toolCallId: string; + title: string; + locations?: unknown[]; + kind: acp.ToolKind; + }; + _meta?: Record; +} + +export function buildHostInputAgentCapability(): Record { + return { + version: 1, + requestUserInput: true, + method: ACP_HOST_INPUT_REQUEST_METHOD, + supportedKinds: [...ACP_HOST_INPUT_KINDS], + }; +} + +export function resolveHostInputSupport( + clientCapabilities?: acp.ClientCapabilities, +): HostInputSupport { + const capability = clientCapabilities?._meta?.[ACP_HOST_INPUT_CAPABILITY_KEY]; + const parsed = HostInputCapabilitySchema.safeParse(capability); + if (!parsed.success) { + return { + askUser: false, + exitPlanMode: false, + }; + } + + if (parsed.data.requestUserInput !== true) { + return { + askUser: false, + exitPlanMode: false, + }; + } + + const supportedKinds = new Set( + parsed.data.supportedKinds ?? ACP_HOST_INPUT_KINDS, + ); + + return { + askUser: supportedKinds.has('ask_user'), + exitPlanMode: supportedKinds.has('exit_plan_mode'), + }; +} + +export async function requestHostInput( + connection: acp.AgentSideConnection, + request: HostInputRequest, +): Promise { + const response = await connection.extMethod(ACP_HOST_INPUT_REQUEST_METHOD, { + ...request, + }); + return HostInputResponseSchema.parse(response); +} + +export function buildExitPlanModeQuestions(planContent: string): Question[] { + return [ + { + type: QuestionType.CHOICE, + header: 'Approval', + question: planContent, + options: [ + { + label: PLAN_APPROVAL_AUTO_OPTION, + description: 'Approves plan and allows tools to run automatically', + }, + { + label: PLAN_APPROVAL_MANUAL_OPTION, + description: 'Approves plan but requires confirmation for each tool', + }, + ], + placeholder: 'Type your feedback...', + multiSelect: false, + unconstrainedHeight: false, + }, + ]; +} + +export function mapExitPlanModeAnswers(answers: Record): { + approved: boolean; + approvalMode?: ApprovalMode; + feedback?: string; +} { + const answer = answers[FIRST_QUESTION_INDEX]; + + if (answer === PLAN_APPROVAL_AUTO_OPTION) { + return { + approved: true, + approvalMode: ApprovalMode.AUTO_EDIT, + }; + } + + if (answer === PLAN_APPROVAL_MANUAL_OPTION) { + return { + approved: true, + approvalMode: ApprovalMode.DEFAULT, + }; + } + + if (answer) { + return { + approved: false, + feedback: answer, + }; + } + + return { + approved: false, + }; +} diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 04df366a983..ccf2199453a 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2416,6 +2416,19 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).toContain('ask_user'); }); + it('should keep ask_user enabled in ACP mode when host input support is enabled', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + { acpAskUserEnabled: true }, + ); + expect(config.getExcludeTools()).not.toContain('ask_user'); + }); + it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { process.stdin.isTTY = false; process.argv = [ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c1ac3e57dd4..d650972d943 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -511,6 +511,7 @@ export function isDebugMode(argv: CliArgs): boolean { export interface LoadCliConfigOptions { cwd?: string; + acpAskUserEnabled?: boolean; projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; @@ -523,7 +524,11 @@ export async function loadCliConfig( argv: CliArgs, options: LoadCliConfigOptions = {}, ): Promise { - const { cwd = process.cwd(), projectHooks } = options; + const { + cwd = process.cwd(), + projectHooks, + acpAskUserEnabled = false, + } = options; const debugMode = isDebugMode(argv); const worktreeSettings = @@ -752,12 +757,13 @@ export async function loadCliConfig( // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; - if (!interactive || isAcpMode) { + if (!interactive || (isAcpMode && !acpAskUserEnabled)) { // The Policy Engine natively handles headless safety by translating ASK_USER // decisions to DENY. However, we explicitly block ask_user here to guarantee - // it can never be allowed via a high-priority policy rule when no human is present. - // We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission, - // breaking conversational flows. + // it can never be allowed via a high-priority policy rule when no human is + // present. In ACP mode, ask_user stays excluded by default and is only + // enabled when the client explicitly opts into Gemini CLI host-input + // requests. extraExcludes.push(ASK_USER_TOOL_NAME); } From bf4021405f2bc0d41cff5879751baba0bf21a44c Mon Sep 17 00:00:00 2001 From: Benjamin Western Date: Sat, 4 Apr 2026 14:59:10 +1100 Subject: [PATCH 2/3] fix(cli): tighten ACP host input handling --- packages/cli/src/acp/acpClient.ts | 15 +++++---------- packages/cli/src/acp/hostInput.ts | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 6a6b72dc4aa..8534134882e 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -35,6 +35,7 @@ import { partListUnionToString, LlmRole, ApprovalMode, + checkExhaustive, getVersion, convertSessionToClientHistory, DEFAULT_GEMINI_MODEL, @@ -1080,7 +1081,7 @@ export class Session { await confirmationDetails.onConfirm( ToolConfirmationOutcome.ProceedOnce, - mapExitPlanModeAnswers(output.answers ?? {}), + mapExitPlanModeAnswers(output.answers), ); return ToolConfirmationOutcome.ProceedOnce; } @@ -2066,11 +2067,11 @@ function toPermissionOptions( break; case 'ask_user': case 'exit_plan_mode': + case 'sandbox_expansion': // askuser and exit_plan_mode don't need "always allow" options break; default: - // No "always allow" options for other types. - break; + return checkExhaustive(confirmation); } } @@ -2087,18 +2088,12 @@ function toPermissionOptions( case 'sandbox_expansion': break; default: - return assertUnreachableConfirmation(confirmation); + return checkExhaustive(confirmation); } return options; } -function assertUnreachableConfirmation(value: never): never { - throw new Error( - `Unhandled tool confirmation details: ${JSON.stringify(value)}`, - ); -} - /** * Maps our internal tool kind to the ACP ToolKind. * Fallback to 'other' for kinds that are not supported by the ACP protocol. diff --git a/packages/cli/src/acp/hostInput.ts b/packages/cli/src/acp/hostInput.ts index 6973c1b5adf..a4fcff01e4e 100644 --- a/packages/cli/src/acp/hostInput.ts +++ b/packages/cli/src/acp/hostInput.ts @@ -54,7 +54,7 @@ export interface HostInputRequest { toolCall?: { toolCallId: string; title: string; - locations?: unknown[]; + locations?: acp.ToolCallLocation[]; kind: acp.ToolKind; }; _meta?: Record; From d12bc0b3e30c5f925e6545821b1d6019c6a37a4c Mon Sep 17 00:00:00 2001 From: Benjamin Western Date: Sat, 4 Apr 2026 17:17:10 +1100 Subject: [PATCH 3/3] fix(cli): trim host input feedback string This prevents whitespace-only feedback from being accepted as a valid response and adds tests to cover the expected behavior. --- packages/cli/src/acp/hostInput.test.ts | 60 ++++++++++++++++++++++++++ packages/cli/src/acp/hostInput.ts | 5 ++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/acp/hostInput.test.ts diff --git a/packages/cli/src/acp/hostInput.test.ts b/packages/cli/src/acp/hostInput.test.ts new file mode 100644 index 00000000000..f2a9e1e54e0 --- /dev/null +++ b/packages/cli/src/acp/hostInput.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { ApprovalMode } from '@google/gemini-cli-core'; +import { + mapExitPlanModeAnswers, + PLAN_APPROVAL_AUTO_OPTION, + PLAN_APPROVAL_MANUAL_OPTION, +} from './hostInput.js'; + +describe('mapExitPlanModeAnswers', () => { + it('should return approved: true and AUTO_EDIT for auto option', () => { + const result = mapExitPlanModeAnswers({ '0': PLAN_APPROVAL_AUTO_OPTION }); + expect(result).toEqual({ + approved: true, + approvalMode: ApprovalMode.AUTO_EDIT, + }); + }); + + it('should return approved: true and DEFAULT for manual option', () => { + const result = mapExitPlanModeAnswers({ '0': PLAN_APPROVAL_MANUAL_OPTION }); + expect(result).toEqual({ + approved: true, + approvalMode: ApprovalMode.DEFAULT, + }); + }); + + it('should return approved: false and feedback for other strings', () => { + const result = mapExitPlanModeAnswers({ '0': 'Some feedback' }); + expect(result).toEqual({ + approved: false, + feedback: 'Some feedback', + }); + }); + + it('should return approved: false for whitespace-only feedback', () => { + const result = mapExitPlanModeAnswers({ '0': ' ' }); + expect(result).toEqual({ + approved: false, + }); + }); + + it('should return approved: false for empty string', () => { + const result = mapExitPlanModeAnswers({ '0': '' }); + expect(result).toEqual({ + approved: false, + }); + }); + + it('should return approved: false for undefined', () => { + const result = mapExitPlanModeAnswers({}); + expect(result).toEqual({ + approved: false, + }); + }); +}); diff --git a/packages/cli/src/acp/hostInput.ts b/packages/cli/src/acp/hostInput.ts index a4fcff01e4e..53b69151578 100644 --- a/packages/cli/src/acp/hostInput.ts +++ b/packages/cli/src/acp/hostInput.ts @@ -152,10 +152,11 @@ export function mapExitPlanModeAnswers(answers: Record): { }; } - if (answer) { + const trimmedAnswer = answer?.trim(); + if (trimmedAnswer) { return { approved: false, - feedback: answer, + feedback: trimmedAnswer, }; }