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