Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Session, SessionOptions } from '@github/copilot/sdk';6import * as l10n from '@vscode/l10n';7import type { CancellationToken, ChatParticipantToolToken, TextDocument } from 'vscode';8import { ILogService } from '../../../../platform/log/common/logService';9import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';10import { Delayer } from '../../../../util/vs/base/common/async';11import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';12import { isEqual } from '../../../../util/vs/base/common/resources';13import { LanguageModelTextPart, Uri } from '../../../../vscodeTypes';14import { IToolsService } from '../../../tools/common/toolsService';1516type ExitPlanModeActionType = Parameters<NonNullable<SessionOptions['onExitPlanMode']>>[0]['actions'][number];1718const actionDescriptions: Record<ExitPlanModeActionType, { label: string; description: string }> = {19'autopilot': { label: l10n.t("Implement with Autopilot"), description: l10n.t('Auto-approve all tool calls and continue until the task is done.') },20'autopilot_fleet': { label: l10n.t("Implement with Autopilot Fleet"), description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') },21'interactive': { label: l10n.t("Implement Plan"), description: l10n.t('Implement the plan, asking for input and approval for each action.') },22'exit_only': { label: l10n.t("Approve Plan Only"), description: l10n.t('Approve the plan without executing it. I will implement it myself.') },23};2425/**26* Monitors a plan.md file for user edits and syncs saved changes back to the27* SDK session. Uses a {@link Delayer} to debounce rapid `onDidChangeTextDocument`28* events. Only writes to the SDK when the document is no longer dirty (i.e. the29* user has saved the file).30*/31class PlanFileMonitor extends DisposableStore {32private readonly _delayer: Delayer<void>;33private _pendingWrite: Promise<void> = Promise.resolve();34private _lastChangedDocument: TextDocument | undefined;3536constructor(37planUri: Uri,38private readonly _session: Session,39workspaceService: IWorkspaceService,40private readonly _logService: ILogService,41) {42super();43this._delayer = this.add(new Delayer<void>(100));4445this.add(workspaceService.onDidChangeTextDocument(e => {46if (e.contentChanges.length === 0 || !isEqual(e.document.uri, planUri)) {47return;48}49this._lastChangedDocument = e.document;50this._delayer.trigger(() => this._syncIfSaved());51}));52}5354private _syncIfSaved(): void {55const doc = this._lastChangedDocument;56if (!doc || doc.isDirty) {57return;58}59const content = doc.getText();60this._logService.trace('[ExitPlanModeHandler] Plan file saved by user, syncing to SDK session');61this._pendingWrite = this._session.writePlan(content).catch(err => {62this._logService.error(err, '[ExitPlanModeHandler] Failed to write plan changes to SDK session');63});64}6566/**67* Flushes any pending debounced sync and waits for the in-flight68* `writePlan` call to complete. Call this before disposing to ensure69* the last saved plan content has been written to the SDK.70*/71async flush(): Promise<void> {72if (this._delayer.isTriggered()) {73this._delayer.cancel();74this._syncIfSaved();75}76await this._pendingWrite;77}78}7980export interface ExitPlanModeEventData {81readonly requestId: string;82readonly summary: string;83readonly actions: string[];84readonly recommendedAction: string;85}8687export interface ExitPlanModeResponse {88readonly approved: boolean;89readonly selectedAction?: ExitPlanModeActionType;90readonly autoApproveEdits?: boolean;91readonly feedback?: string;92}9394/**95* Handles the `exit_plan_mode.requested` SDK event.96*97* In **autopilot** mode the handler auto-selects the best action without user98* interaction. In **interactive** mode the handler shows a question to the user99* and monitors plan.md for edits while waiting for the answer.100*/101export function handleExitPlanMode(102event: ExitPlanModeEventData,103session: Session,104permissionLevel: string | undefined,105toolInvocationToken: ChatParticipantToolToken | undefined,106workspaceService: IWorkspaceService,107logService: ILogService,108toolService: IToolsService,109token: CancellationToken,110): Promise<ExitPlanModeResponse> {111if (permissionLevel === 'autopilot') {112return Promise.resolve(resolveAutopilot(event, logService));113}114115if (!(toolInvocationToken as unknown)) {116logService.warn('[ExitPlanModeHandler] No toolInvocationToken available, cannot request exit plan mode approval');117return Promise.resolve({ approved: false });118}119120return resolveInteractive(event, session, permissionLevel, toolInvocationToken!, workspaceService, logService, toolService, token);121}122123function resolveAutopilot(event: ExitPlanModeEventData, logService: ILogService): ExitPlanModeResponse {124logService.trace('[ExitPlanModeHandler] Auto-approving exit plan mode in autopilot');125const choices = (event.actions as ExitPlanModeActionType[]) ?? [];126127if (event.recommendedAction && choices.includes(event.recommendedAction as ExitPlanModeActionType)) {128return { approved: true, selectedAction: event.recommendedAction as ExitPlanModeActionType, autoApproveEdits: true };129}130for (const action of ['autopilot', 'autopilot_fleet', 'interactive', 'exit_only'] as const) {131if (choices.includes(action)) {132const autoApproveEdits = action === 'autopilot' || action === 'autopilot_fleet' ? true : undefined;133return { approved: true, selectedAction: action, autoApproveEdits };134}135}136return { approved: true, autoApproveEdits: true };137}138139async function resolveInteractive(140event: ExitPlanModeEventData,141session: Session,142permissionLevel: string | undefined,143toolInvocationToken: ChatParticipantToolToken,144workspaceService: IWorkspaceService,145logService: ILogService,146toolService: IToolsService,147token: CancellationToken,148): Promise<ExitPlanModeResponse> {149const planPath = session.getPlanPath();150151// Monitor plan.md for user edits while the exit-plan-mode question is displayed.152const planFileMonitor = planPath ? new PlanFileMonitor(Uri.file(planPath), session, workspaceService, logService) : undefined;153154try {155const actions: { label: string; description: string; default: boolean; permissionLevel?: 'autopilot' }[] = event.actions.map(a => ({156label: actionDescriptions[a as ExitPlanModeActionType]?.label ?? a,157default: a === event.recommendedAction,158description: actionDescriptions[a as ExitPlanModeActionType]?.description ?? '',159...(a === 'autopilot' || a === 'autopilot_fleet' ? { permissionLevel: 'autopilot' as const } : {}),160}));161162const result = await toolService.invokeTool('vscode_reviewPlan', {163input: {164title: l10n.t('Review Plan'),165plan: planPath ? Uri.file(planPath).toString() : undefined,166content: event.summary,167actions,168canProvideFeedback: true169},170toolInvocationToken,171}, token);172173const firstPart = result?.content.at(0);174if (!(firstPart instanceof LanguageModelTextPart) || !firstPart.value) {175return { approved: false };176}177178const answer = JSON.parse(firstPart.value) as {179action?: string;180rejected: boolean;181feedback?: string;182};183184185// Ensure any pending plan writes complete before responding to the SDK.186await planFileMonitor?.flush();187188if (answer.rejected) {189return { approved: false };190}191if (answer.feedback) {192return { approved: false, feedback: answer.feedback, selectedAction: answer.action as ExitPlanModeActionType };193}194195let selectedAction: ExitPlanModeActionType | undefined = undefined;196for (const [action, desc] of Object.entries(actionDescriptions)) {197if (desc.label === answer.action) {198selectedAction = action as ExitPlanModeActionType;199break;200}201}202const autoApproveEdits = permissionLevel === 'autoApprove' ? true : undefined;203return { approved: true, selectedAction, autoApproveEdits };204} finally {205planFileMonitor?.dispose();206}207}208209210