feat(copilotcli):Implement updating plan file in exit plan mode handling#309454
feat(copilotcli):Implement updating plan file in exit plan mode handling#309454DonJayamanne wants to merge 1 commit intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extracts Copilot CLI “exit plan mode” handling into a dedicated handleExitPlanMode helper and adds a new unit test suite, with the goal of supporting updating the SDK session plan when the user edits plan.md during exit-plan approval.
Changes:
- Introduces
exitPlanModeHandler.tsto centralize autopilot/interactive exit-plan decisions and plan syncing logic. - Updates
CopilotCLISessionto delegateexit_plan_mode.requestedhandling to the new helper. - Adds
exitPlanModeHandler.spec.tswith coverage for autopilot/interactive flows and plan file monitoring.
Show a summary per file
| File | Description |
|---|---|
| extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts | New handler + plan file monitor implementation. |
| extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts | Wires the SDK event to the new handler. |
| extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts | New unit tests for handler behavior and monitoring. |
Copilot's findings
Comments suppressed due to low confidence (4)
extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts:85
ExitPlanModeEventDatamodelsactions/recommendedActionas required strings, but existing callers/tests show these fields can be omitted (e.g. actions undefined). As written,resolveInteractivewill throw when it doesevent.actions.map(...)ifactionsis missing. Make these fields optional (or default them defensively inhandleExitPlanMode) and ensure all usages tolerateundefined/empty values.
export interface ExitPlanModeEventData {
readonly requestId: string;
readonly actions: string[];
readonly recommendedAction: string;
}
extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts:59
- Plan syncing currently depends on
onDidChangeTextDocumentevents and only writes whendoc.isDirty === false. Saving a document typically does not fire a change event, so if the user saves after the delayer fires (or without a format-on-save edit), the SDK session will never be updated. Consider listening to an explicit save event (preferred) or re-triggering/polling until the document becomes clean while the question is open.
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;
}
extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts:65
_pendingWriteis replaced on each sync, allowing multiplewritePlancalls to overlap and potentially complete out-of-order (older write finishing last). Chain writes onto the previous promise (serialize) so the final plan content is deterministic, andflush()truly waits for all queued writes.
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');
});
}
extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts:186
- Interactive resolution assumes
answer.selected[0]exists wheneverfreeTextis empty. If the UI returns an answer with no selection (orskipped: true), this will respondapproved: truewith an invalid/undefinedselectedAction. Handleanswer.skipped/ empty selections by returning{ approved: false }(or falling back to a safe default fromevent.actions).
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 };
- Files reviewed: 3/3 changed files
- Comments generated: 2
| '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.') }, |
There was a problem hiding this comment.
The option labels in actionDescriptions are user-visible but are not localized. Wrap these labels with l10n.t(...) (as you already do for descriptions) so the question UI can be translated consistently.
This issue also appears in the following locations of the same file:
- line 46
- line 60
- line 81
- line 171
| '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.') }, | |
| 'autopilot': { label: l10n.t('Autopilot'), description: l10n.t('Auto-approve all tool calls and continue until the task is done') }, | |
| 'interactive': { label: l10n.t('Interactive'), description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') }, | |
| 'exit_only': { label: l10n.t('Approve and exit'), description: l10n.t('Exit planning, but do not execute the plan. I will execute the plan myself.') }, | |
| 'autopilot_fleet': { label: l10n.t('Autopilot Fleet'), description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') }, |
| // Allow debouncer to fire | ||
| await new Promise(r => setTimeout(r, 400)); | ||
|
|
There was a problem hiding this comment.
These tests wait ~400ms using real timers to let the Delayer fire, which slows the suite and can be flaky under load. Prefer vi.useFakeTimers() and vi.advanceTimersByTime(...) (or runAllTimersAsync) to make the debounce behavior deterministic and faster.
No description provided.