-
Notifications
You must be signed in to change notification settings - Fork 39.3k
Expand file tree
/
Copy pathexitPlanModeHandler.ts
More file actions
190 lines (168 loc) · 7.68 KB
/
exitPlanModeHandler.ts
File metadata and controls
190 lines (168 loc) · 7.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
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();
}
}