Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { enrichToolInvocationWithSubagentMetadata, getAffectedUrisForEditTool, i
import { getCopilotCLISessionStateDir } from './cliHelpers';
import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
import { ICopilotCLIImageSupport } from './copilotCLIImageSupport';
import { handleExitPlanMode } from './exitPlanModeHandler';
import { PermissionRequest, requestPermission, requiresFileEditconfirmation } from './permissionHelpers';
import { IQuestion, IUserQuestionHandler } from './userInputHelpers';

Expand Down Expand Up @@ -441,82 +442,23 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
if (shouldHandleExitPlanModeRequests) {
disposables.add(toDisposable(this._sdkSession.on('exit_plan_mode.requested', async (event) => {
this.updateArtifacts();
type ActionType = Parameters<NonNullable<SessionOptions['onExitPlanMode']>>[0]['actions'][number];
if (this._permissionLevel === 'autopilot') {
this.logService.trace('[CopilotCLISession] Auto-approving exit plan mode in autopilot');
const choices: ActionType[] = (event.data.actions as ActionType[]) ?? [];
if (event.data.recommendedAction && choices.includes(event.data.recommendedAction as ActionType)) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, selectedAction: event.data.recommendedAction as ActionType, autoApproveEdits: true });
return;
}
if (choices.includes('autopilot')) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true });
return;
}
if (choices.includes('autopilot_fleet')) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, selectedAction: 'autopilot_fleet', autoApproveEdits: true });
return;
}
if (choices.includes('interactive')) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, selectedAction: 'interactive' });
return;
}
if (choices.includes('exit_only')) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, selectedAction: 'exit_only' });
return;
}
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, autoApproveEdits: true });
return;
}
if (!(this._toolInvocationToken as unknown)) {
this.logService.warn('[ConfirmationTool] No toolInvocationToken available, cannot request exit plan mode approval');
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false });
return;
}
const actionDescriptions: Record<string, { label: string; description: string }> = {
'autopilot': { label: 'Autopilot', description: l10n.t('Auto-approve all tool calls and continue until the task is done') },
'interactive': { label: 'Interactive', description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') },
'exit_only': { label: 'Approve and exit', description: l10n.t('Exit planning, but do not execute the plan. I will execute the plan myself.') },
'autopilot_fleet': { label: 'Autopilot Fleet', description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') },
} satisfies Record<ActionType, { label: string; description: string }>;

const approved = true;
try {
const planPath = this._sdkSession.getPlanPath();

const userInputRequest: IQuestion = {
question: planPath ? l10n.t('Approve this plan {0}?', `[Plan.md](${Uri.file(planPath).toString()})`) : l10n.t('Approve this plan?'),
header: l10n.t('Approve this plan?'),
options: event.data.actions.map(a => ({
label: actionDescriptions[a]?.label ?? a,
recommended: a === event.data.recommendedAction,
description: actionDescriptions[a]?.description ?? '',
})),
allowFreeformInput: true,
};
const answer = await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token);
const response = await handleExitPlanMode(
event.data,
this._sdkSession,
this._permissionLevel,
this._toolInvocationToken,
this.workspaceService,
this.logService,
this._userQuestionHandler,
token,
);
flushPendingInvocationMessages();
if (!answer) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false });
return;
}
if (answer.freeText) {
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false, feedback: answer.freeText });
} else {
let selectedAction: ActionType = answer.selected[0] as ActionType;
Object.entries(actionDescriptions).forEach(([action, item]) => {
if (item.label === selectedAction) {
selectedAction = action as ActionType;
}
});
const autoApproveEdits = approved && this._permissionLevel === 'autoApprove' ? true : undefined;
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: true, selectedAction, autoApproveEdits });
}
this._sdkSession.respondToExitPlanMode(event.data.requestId, response);
} catch (error) {
this.logService.error(error, '[ConfirmationTool] Error showing confirmation tool for exit plan mode');
this.logService.error(error, '[CopilotCLISession] Error handling exit plan mode');
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false });
}
this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false });

})));
}
disposables.add(toDisposable(this._sdkSession.on('user_input.requested', async (event) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type { Session, SessionOptions } from '@github/copilot/sdk';
import * as l10n from '@vscode/l10n';
import type { CancellationToken, ChatParticipantToolToken, TextDocument } from 'vscode';
import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { Delayer } from '../../../../util/vs/base/common/async';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
import { isEqual } from '../../../../util/vs/base/common/resources';
import { Uri } from '../../../../vscodeTypes';
import { IQuestion, IUserQuestionHandler } from './userInputHelpers';

type ExitPlanModeActionType = Parameters<NonNullable<SessionOptions['onExitPlanMode']>>[0]['actions'][number];

const actionDescriptions: Record<ExitPlanModeActionType, { label: string; description: string }> = {
'autopilot': { label: 'Autopilot', description: l10n.t('Auto-approve all tool calls and continue until the task is done') },
'interactive': { label: 'Interactive', description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') },
'exit_only': { label: 'Approve and exit', description: l10n.t('Exit planning, but do not execute the plan. I will execute the plan myself.') },
'autopilot_fleet': { label: 'Autopilot Fleet', description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') },
};

/**
* Monitors a plan.md file for user edits and syncs saved changes back to the
* SDK session. Uses a {@link Delayer} to debounce rapid `onDidChangeTextDocument`
* events. Only writes to the SDK when the document is no longer dirty (i.e. the
* user has saved the file).
*/
class PlanFileMonitor extends DisposableStore {
private readonly _delayer: Delayer<void>;
private _pendingWrite: Promise<void> = Promise.resolve();
private _lastChangedDocument: TextDocument | undefined;

constructor(
planUri: Uri,
private readonly _session: Session,
workspaceService: IWorkspaceService,
private readonly _logService: ILogService,
) {
super();
this._delayer = this.add(new Delayer<void>(100));

this.add(workspaceService.onDidChangeTextDocument(e => {
if (e.contentChanges.length === 0 || !isEqual(e.document.uri, planUri)) {
return;
}
this._lastChangedDocument = e.document;
this._delayer.trigger(() => this._syncIfSaved());
}));
}

private _syncIfSaved(): void {
const doc = this._lastChangedDocument;
if (!doc || doc.isDirty) {
return;
}
const content = doc.getText();
this._logService.trace('[ExitPlanModeHandler] Plan file saved by user, syncing to SDK session');
this._pendingWrite = this._session.writePlan(content).catch(err => {
this._logService.error(err, '[ExitPlanModeHandler] Failed to write plan changes to SDK session');
});
}

/**
* Flushes any pending debounced sync and waits for the in-flight
* `writePlan` call to complete. Call this before disposing to ensure
* the last saved plan content has been written to the SDK.
*/
async flush(): Promise<void> {
if (this._delayer.isTriggered()) {
this._delayer.cancel();
this._syncIfSaved();
}
await this._pendingWrite;
}
}

export interface ExitPlanModeEventData {
readonly requestId: string;
readonly actions: string[];
readonly recommendedAction: string;
}

export interface ExitPlanModeResponse {
readonly approved: boolean;
readonly selectedAction?: ExitPlanModeActionType;
readonly autoApproveEdits?: boolean;
readonly feedback?: string;
}

/**
* Handles the `exit_plan_mode.requested` SDK event.
*
* In **autopilot** mode the handler auto-selects the best action without user
* interaction. In **interactive** mode the handler shows a question to the user
* and monitors plan.md for edits while waiting for the answer.
*/
export function handleExitPlanMode(
event: ExitPlanModeEventData,
session: Session,
permissionLevel: string | undefined,
toolInvocationToken: ChatParticipantToolToken | undefined,
workspaceService: IWorkspaceService,
logService: ILogService,
userQuestionHandler: IUserQuestionHandler,
token: CancellationToken,
): Promise<ExitPlanModeResponse> {
if (permissionLevel === 'autopilot') {
return Promise.resolve(resolveAutopilot(event, logService));
}

if (!(toolInvocationToken as unknown)) {
logService.warn('[ExitPlanModeHandler] No toolInvocationToken available, cannot request exit plan mode approval');
return Promise.resolve({ approved: false });
}

return resolveInteractive(event, session, permissionLevel, toolInvocationToken!, workspaceService, logService, userQuestionHandler, token);
}

function resolveAutopilot(event: ExitPlanModeEventData, logService: ILogService): ExitPlanModeResponse {
logService.trace('[ExitPlanModeHandler] Auto-approving exit plan mode in autopilot');
const choices = (event.actions as ExitPlanModeActionType[]) ?? [];

if (event.recommendedAction && choices.includes(event.recommendedAction as ExitPlanModeActionType)) {
return { approved: true, selectedAction: event.recommendedAction as ExitPlanModeActionType, autoApproveEdits: true };
}
for (const action of ['autopilot', 'autopilot_fleet', 'interactive', 'exit_only'] as const) {
if (choices.includes(action)) {
const autoApproveEdits = action === 'autopilot' || action === 'autopilot_fleet' ? true : undefined;
return { approved: true, selectedAction: action, autoApproveEdits };
}
}
return { approved: true, autoApproveEdits: true };
}

async function resolveInteractive(
event: ExitPlanModeEventData,
session: Session,
permissionLevel: string | undefined,
toolInvocationToken: ChatParticipantToolToken,
workspaceService: IWorkspaceService,
logService: ILogService,
userQuestionHandler: IUserQuestionHandler,
token: CancellationToken,
): Promise<ExitPlanModeResponse> {
const planPath = session.getPlanPath();

// Monitor plan.md for user edits while the exit-plan-mode question is displayed.
const planFileMonitor = planPath ? new PlanFileMonitor(Uri.file(planPath), session, workspaceService, logService) : undefined;

try {
const userInputRequest: IQuestion = {
question: planPath ? l10n.t('Approve this plan {0}?', `[Plan.md](${Uri.file(planPath).toString()})`) : l10n.t('Approve this plan?'),
header: l10n.t('Approve this plan?'),
options: event.actions.map(a => ({
label: actionDescriptions[a as ExitPlanModeActionType]?.label ?? a,
recommended: a === event.recommendedAction,
description: actionDescriptions[a as ExitPlanModeActionType]?.description ?? '',
})),
allowFreeformInput: true,
};

const answer = await userQuestionHandler.askUserQuestion(userInputRequest, toolInvocationToken as unknown as never, token);

// Ensure any pending plan writes complete before responding to the SDK.
await planFileMonitor?.flush();

if (!answer) {
return { approved: false };
}
if (answer.freeText) {
return { approved: false, feedback: answer.freeText };
}

let selectedAction: ExitPlanModeActionType = answer.selected[0] as ExitPlanModeActionType;
for (const [action, desc] of Object.entries(actionDescriptions)) {
if (desc.label === selectedAction) {
selectedAction = action as ExitPlanModeActionType;
break;
}
}
const autoApproveEdits = permissionLevel === 'autoApprove' ? true : undefined;
return { approved: true, selectedAction, autoApproveEdits };
} finally {
planFileMonitor?.dispose();
}
}
Loading
Loading