-
Notifications
You must be signed in to change notification settings - Fork 39.2k
feat(copilotcli):Implement updating plan file in exit plan mode handling #309454
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 190 additions & 0 deletions
190
extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.