mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
Introduce a new tool for exiting plan mode that presents users with a dedicated approval dialog. Users can review the plan content and choose to approve (with auto-edit or default approval modes) or provide feedback for revisions.
302 lines
9.1 KiB
TypeScript
302 lines
9.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { on } from 'node:events';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
|
import {
|
|
MessageBusType,
|
|
type ToolConfirmationResponse,
|
|
type SerializableConfirmationDetails,
|
|
} from '../confirmation-bus/types.js';
|
|
import {
|
|
ToolConfirmationOutcome,
|
|
type ToolConfirmationPayload,
|
|
type ToolCallConfirmationDetails,
|
|
type EditConfirmationPayload,
|
|
} from '../tools/tools.js';
|
|
import type { ValidatingToolCall, WaitingToolCall } from './types.js';
|
|
import type { Config } from '../config/config.js';
|
|
import type { SchedulerStateManager } from './state-manager.js';
|
|
import type { ToolModificationHandler } from './tool-modifier.js';
|
|
import type { EditorType } from '../utils/editor.js';
|
|
import type { DiffUpdateResult } from '../ide/ide-client.js';
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
|
|
|
export interface ConfirmationResult {
|
|
outcome: ToolConfirmationOutcome;
|
|
payload?: ToolConfirmationPayload;
|
|
}
|
|
|
|
/**
|
|
* Result of the full confirmation flow, including any user modifications.
|
|
*/
|
|
export interface ResolutionResult {
|
|
outcome: ToolConfirmationOutcome;
|
|
lastDetails?: SerializableConfirmationDetails;
|
|
}
|
|
|
|
/**
|
|
* Waits for a confirmation response with the matching correlationId.
|
|
*
|
|
* NOTE: It is the caller's responsibility to manage the lifecycle of this wait
|
|
* via the provided AbortSignal. To prevent memory leaks and "zombie" listeners
|
|
* in the event of a lost connection (e.g. IDE crash), it is strongly recommended
|
|
* to use a signal with a timeout (e.g. AbortSignal.timeout(ms)).
|
|
*
|
|
* @param messageBus The MessageBus to listen on.
|
|
* @param correlationId The correlationId to match.
|
|
* @param signal An AbortSignal to cancel the wait and cleanup listeners.
|
|
*/
|
|
export async function awaitConfirmation(
|
|
messageBus: MessageBus,
|
|
correlationId: string,
|
|
signal: AbortSignal,
|
|
): Promise<ConfirmationResult> {
|
|
if (signal.aborted) {
|
|
throw new Error('Operation cancelled');
|
|
}
|
|
|
|
try {
|
|
for await (const [msg] of on(
|
|
messageBus,
|
|
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
|
{ signal },
|
|
)) {
|
|
const response = msg as ToolConfirmationResponse;
|
|
if (response.correlationId === correlationId) {
|
|
return {
|
|
outcome:
|
|
response.outcome ??
|
|
// TODO: Remove legacy confirmed boolean fallback once migration complete
|
|
(response.confirmed
|
|
? ToolConfirmationOutcome.ProceedOnce
|
|
: ToolConfirmationOutcome.Cancel),
|
|
payload: response.payload,
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted || (error as Error).name === 'AbortError') {
|
|
throw new Error('Operation cancelled');
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// This point should only be reached if the iterator closes without resolving,
|
|
// which generally means the signal was aborted.
|
|
throw new Error('Operation cancelled');
|
|
}
|
|
|
|
/**
|
|
* Manages the interactive confirmation loop, handling user modifications
|
|
* via inline diffs or external editors (Vim).
|
|
*/
|
|
export async function resolveConfirmation(
|
|
toolCall: ValidatingToolCall,
|
|
signal: AbortSignal,
|
|
deps: {
|
|
config: Config;
|
|
messageBus: MessageBus;
|
|
state: SchedulerStateManager;
|
|
modifier: ToolModificationHandler;
|
|
getPreferredEditor: () => EditorType | undefined;
|
|
schedulerId: string;
|
|
},
|
|
): Promise<ResolutionResult> {
|
|
const { state } = deps;
|
|
const callId = toolCall.request.callId;
|
|
let outcome = ToolConfirmationOutcome.ModifyWithEditor;
|
|
let lastDetails: SerializableConfirmationDetails | undefined;
|
|
|
|
// Loop exists to allow the user to modify the parameters and see the new
|
|
// diff.
|
|
while (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
|
|
if (signal.aborted) throw new Error('Operation cancelled');
|
|
|
|
const currentCall = state.getToolCall(callId);
|
|
if (!currentCall || !('invocation' in currentCall)) {
|
|
throw new Error(`Tool call ${callId} lost during confirmation loop`);
|
|
}
|
|
const currentInvocation = currentCall.invocation;
|
|
|
|
const details = await currentInvocation.shouldConfirmExecute(signal);
|
|
if (!details) {
|
|
outcome = ToolConfirmationOutcome.ProceedOnce;
|
|
break;
|
|
}
|
|
|
|
await notifyHooks(deps, details);
|
|
|
|
const correlationId = randomUUID();
|
|
const serializableDetails = details as SerializableConfirmationDetails;
|
|
lastDetails = serializableDetails;
|
|
|
|
const ideConfirmation =
|
|
'ideConfirmation' in details ? details.ideConfirmation : undefined;
|
|
|
|
state.updateStatus(callId, 'awaiting_approval', {
|
|
confirmationDetails: serializableDetails,
|
|
correlationId,
|
|
});
|
|
|
|
const response = await waitForConfirmation(
|
|
deps.messageBus,
|
|
correlationId,
|
|
signal,
|
|
ideConfirmation,
|
|
);
|
|
outcome = response.outcome;
|
|
|
|
if ('onConfirm' in details && typeof details.onConfirm === 'function') {
|
|
await details.onConfirm(outcome, response.payload);
|
|
}
|
|
|
|
if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
|
|
await handleExternalModification(deps, toolCall, signal);
|
|
} else if (
|
|
response.payload?.type === 'edit' &&
|
|
response.payload.newContent
|
|
) {
|
|
await handleInlineModification(deps, toolCall, response.payload, signal);
|
|
outcome = ToolConfirmationOutcome.ProceedOnce;
|
|
}
|
|
}
|
|
|
|
return { outcome, lastDetails };
|
|
}
|
|
|
|
/**
|
|
* Fires hook notifications.
|
|
*/
|
|
async function notifyHooks(
|
|
deps: { config: Config; messageBus: MessageBus },
|
|
details: ToolCallConfirmationDetails,
|
|
): Promise<void> {
|
|
if (deps.config.getHookSystem()) {
|
|
await deps.config.getHookSystem()?.fireToolNotificationEvent({
|
|
...details,
|
|
// Pass no-op onConfirm to satisfy type definition; side-effects via
|
|
// callbacks are disallowed.
|
|
onConfirm: async () => {},
|
|
} as ToolCallConfirmationDetails);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles modification via an external editor (e.g. Vim).
|
|
*/
|
|
async function handleExternalModification(
|
|
deps: {
|
|
state: SchedulerStateManager;
|
|
modifier: ToolModificationHandler;
|
|
getPreferredEditor: () => EditorType | undefined;
|
|
},
|
|
toolCall: ValidatingToolCall,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
const { state, modifier, getPreferredEditor } = deps;
|
|
const editor = getPreferredEditor();
|
|
if (!editor) return;
|
|
|
|
const result = await modifier.handleModifyWithEditor(
|
|
state.firstActiveCall as WaitingToolCall,
|
|
editor,
|
|
signal,
|
|
);
|
|
if (result) {
|
|
const newInvocation = toolCall.tool.build(result.updatedParams);
|
|
state.updateArgs(
|
|
toolCall.request.callId,
|
|
result.updatedParams,
|
|
newInvocation,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles modification via inline payload (e.g. from IDE or TUI).
|
|
*/
|
|
async function handleInlineModification(
|
|
deps: { state: SchedulerStateManager; modifier: ToolModificationHandler },
|
|
toolCall: ValidatingToolCall,
|
|
payload: EditConfirmationPayload,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
const { state, modifier } = deps;
|
|
const result = await modifier.applyInlineModify(
|
|
state.firstActiveCall as WaitingToolCall,
|
|
payload,
|
|
signal,
|
|
);
|
|
if (result) {
|
|
const newInvocation = toolCall.tool.build(result.updatedParams);
|
|
state.updateArgs(
|
|
toolCall.request.callId,
|
|
result.updatedParams,
|
|
newInvocation,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits for user confirmation, allowing either the MessageBus (TUI) or IDE to
|
|
* resolve it.
|
|
*/
|
|
async function waitForConfirmation(
|
|
messageBus: MessageBus,
|
|
correlationId: string,
|
|
signal: AbortSignal,
|
|
ideConfirmation?: Promise<DiffUpdateResult>,
|
|
): Promise<ConfirmationResult> {
|
|
// Create a controller to abort the bus listener if the IDE wins (or vice versa)
|
|
const raceController = new AbortController();
|
|
const raceSignal = raceController.signal;
|
|
|
|
// Propagate the parent signal's abort to our race controller
|
|
const onParentAbort = () => raceController.abort();
|
|
if (signal.aborted) {
|
|
raceController.abort();
|
|
} else {
|
|
signal.addEventListener('abort', onParentAbort);
|
|
}
|
|
|
|
try {
|
|
const busPromise = awaitConfirmation(messageBus, correlationId, raceSignal);
|
|
|
|
if (!ideConfirmation) {
|
|
return await busPromise;
|
|
}
|
|
|
|
// Wrap IDE promise to match ConfirmationResult signature
|
|
const idePromise = ideConfirmation
|
|
.then(
|
|
(resolution) =>
|
|
({
|
|
outcome:
|
|
resolution.status === 'accepted'
|
|
? ToolConfirmationOutcome.ProceedOnce
|
|
: ToolConfirmationOutcome.Cancel,
|
|
payload: resolution.content
|
|
? ({ type: 'edit', newContent: resolution.content } as const)
|
|
: undefined,
|
|
}) as ConfirmationResult,
|
|
)
|
|
.catch((error) => {
|
|
debugLogger.warn('Error waiting for confirmation via IDE', error);
|
|
// Return a never-resolving promise so the race continues with the bus
|
|
return new Promise<ConfirmationResult>(() => {});
|
|
});
|
|
|
|
return await Promise.race([busPromise, idePromise]);
|
|
} finally {
|
|
// Cleanup: remove parent listener and abort the race signal to ensure
|
|
// the losing listener (e.g. bus iterator) is closed.
|
|
signal.removeEventListener('abort', onParentAbort);
|
|
raceController.abort();
|
|
}
|
|
}
|