diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 741c65e9582b2..547dbd2877ecb 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -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'; @@ -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>[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 = { - '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; - - 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) => { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts new file mode 100644 index 0000000000000..cf4d39f279eb3 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts @@ -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>[0]['actions'][number]; + +const actionDescriptions: Record = { + '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; + private _pendingWrite: Promise = 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(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 { + 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 { + 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 { + 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(); + } +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts new file mode 100644 index 0000000000000..21c4803a57cc0 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '@github/copilot/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CancellationToken, ChatParticipantToolToken, TextDocumentChangeEvent } from 'vscode'; +import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; +import { Emitter } from '../../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; +import { URI } from '../../../../../util/vs/base/common/uri'; +import { handleExitPlanMode, type ExitPlanModeEventData, type ExitPlanModeResponse } from '../exitPlanModeHandler'; +import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers'; + +// ---------- helpers / mocks ---------- + +function makeEvent(overrides: Partial = {}): ExitPlanModeEventData { + return { + requestId: 'req-1', + actions: ['autopilot', 'interactive', 'exit_only'], + recommendedAction: 'autopilot', + ...overrides, + }; +} + +class StubSession { + public writtenPlans: string[] = []; + constructor(public planPath: string | undefined = '/session/plan.md') { } + getPlanPath(): string | undefined { return this.planPath; } + async writePlan(content: string): Promise { this.writtenPlans.push(content); } +} + +class FakeUserQuestionHandler implements IUserQuestionHandler { + _serviceBrand: undefined; + answer: IQuestionAnswer | undefined = undefined; + lastQuestion: IQuestion | undefined; + + async askUserQuestion(question: IQuestion, _token: ChatParticipantToolToken, _ct: CancellationToken): Promise { + this.lastQuestion = question; + return this.answer; + } +} + +function stubLogService() { + return { + _serviceBrand: undefined, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; +} + +const FAKE_TOKEN = {} as ChatParticipantToolToken; +const CANCEL_TOKEN: CancellationToken = { isCancellationRequested: false, onCancellationRequested: new Emitter().event }; + +// ---------- tests ---------- + +describe('handleExitPlanMode', () => { + const disposables = new DisposableStore(); + let session: StubSession; + let logService: ReturnType; + let workspaceService: NullWorkspaceService; + let questionHandler: FakeUserQuestionHandler; + + beforeEach(() => { + session = new StubSession(); + logService = stubLogService(); + workspaceService = disposables.add(new NullWorkspaceService()); + questionHandler = new FakeUserQuestionHandler(); + }); + + afterEach(() => { + disposables.clear(); + }); + + // ---- autopilot ---- + + describe('autopilot mode', () => { + it('auto-approves with recommended action when it is available', async () => { + const event = makeEvent({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'interactive' }); + const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: true, selectedAction: 'interactive', autoApproveEdits: true }); + }); + + it('falls back to first available action in priority order when no recommended', async () => { + const event = makeEvent({ actions: ['interactive', 'exit_only'], recommendedAction: '' }); + const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: true, selectedAction: 'interactive', autoApproveEdits: undefined }); + }); + + it('prefers autopilot over other actions in fallback order', async () => { + const event = makeEvent({ actions: ['exit_only', 'autopilot'], recommendedAction: '' }); + const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); + }); + + it('prefers autopilot_fleet second in fallback order', async () => { + const event = makeEvent({ actions: ['exit_only', 'autopilot_fleet', 'interactive'], recommendedAction: '' }); + const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: true, selectedAction: 'autopilot_fleet', autoApproveEdits: true }); + }); + + it('returns approved with autoApproveEdits when no actions available', async () => { + const event = makeEvent({ actions: [], recommendedAction: '' }); + const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: true, autoApproveEdits: true }); + }); + + it('sets autoApproveEdits only for autopilot and autopilot_fleet', async () => { + const event1 = makeEvent({ actions: ['exit_only'], recommendedAction: '' }); + const r1 = await handleExitPlanMode(event1, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(r1.autoApproveEdits).toBeUndefined(); + + const event2 = makeEvent({ actions: ['interactive'], recommendedAction: '' }); + const r2 = await handleExitPlanMode(event2, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(r2.autoApproveEdits).toBeUndefined(); + }); + }); + + // ---- no tool invocation token ---- + + describe('missing toolInvocationToken', () => { + it('returns not approved when no token', async () => { + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', undefined, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: false }); + }); + }); + + // ---- interactive mode ---- + + describe('interactive mode', () => { + it('returns not approved when user dismisses the question', async () => { + questionHandler.answer = undefined; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: false }); + }); + + it('returns feedback when user provides freeform text', async () => { + questionHandler.answer = { selected: [], freeText: 'I want changes to the plan', skipped: false }; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: false, feedback: 'I want changes to the plan' }); + }); + + it('returns approved with selected action', async () => { + questionHandler.answer = { selected: ['autopilot'], freeText: null, skipped: false }; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result).toEqual({ approved: true, selectedAction: 'autopilot', autoApproveEdits: undefined }); + }); + + it('maps label back to action type', async () => { + // User selects the label "Autopilot" which should map back to the action key 'autopilot' + questionHandler.answer = { selected: ['Autopilot'], freeText: null, skipped: false }; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result.selectedAction).toBe('autopilot'); + }); + + it('maps "Approve and exit" label to exit_only', async () => { + questionHandler.answer = { selected: ['Approve and exit'], freeText: null, skipped: false }; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result.selectedAction).toBe('exit_only'); + }); + + it('sets autoApproveEdits when permissionLevel is autoApprove', async () => { + questionHandler.answer = { selected: ['interactive'], freeText: null, skipped: false }; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'autoApprove', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result.autoApproveEdits).toBe(true); + }); + + it('does not set autoApproveEdits when permissionLevel is interactive', async () => { + questionHandler.answer = { selected: ['interactive'], freeText: null, skipped: false }; + const event = makeEvent(); + const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(result.autoApproveEdits).toBeUndefined(); + }); + + it('builds question options from event actions with recommended flag', async () => { + questionHandler.answer = { selected: ['Interactive'], freeText: null, skipped: false }; + const event = makeEvent({ actions: ['autopilot', 'exit_only'], recommendedAction: 'exit_only' }); + await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + const q = questionHandler.lastQuestion!; + expect(q.options).toHaveLength(2); + expect(q.options![0]).toEqual(expect.objectContaining({ label: 'Autopilot', recommended: false })); + expect(q.options![1]).toEqual(expect.objectContaining({ label: 'Approve and exit', recommended: true })); + }); + + it('includes plan.md link in question when plan path exists', async () => { + session.planPath = '/session/plan.md'; + questionHandler.answer = { selected: ['interactive'], freeText: null, skipped: false }; + const event = makeEvent(); + await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + const q = questionHandler.lastQuestion!; + expect(q.question).toContain('Plan.md'); + }); + + it('uses plain question when no plan path', async () => { + session.planPath = undefined; + questionHandler.answer = { selected: ['interactive'], freeText: null, skipped: false }; + const event = makeEvent(); + await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + const q = questionHandler.lastQuestion!; + expect(q.question).not.toContain('Plan.md'); + }); + + it('enables freeform input', async () => { + questionHandler.answer = undefined; + const event = makeEvent(); + await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, questionHandler, CANCEL_TOKEN); + expect(questionHandler.lastQuestion!.allowFreeformInput).toBe(true); + }); + }); + + // ---- plan file monitoring ---- + + describe('plan file monitoring', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('syncs saved plan changes to SDK session', async () => { + const planUri = URI.file('/session/plan.md'); + const savedDoc = { + uri: planUri, + isDirty: false, + getText: () => 'updated plan content', + }; + + // Set up a delayed question handler so we can fire document changes while waiting + let resolveQuestion!: (answer: IQuestionAnswer | undefined) => void; + questionHandler.askUserQuestion = (_q, _t, _ct) => { + return new Promise(resolve => { resolveQuestion = resolve; }); + }; + + const promise = handleExitPlanMode( + makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN, + workspaceService, logService, questionHandler, CANCEL_TOKEN, + ); + + // Simulate a saved document change + workspaceService.didChangeTextDocumentEmitter.fire({ + document: savedDoc, + contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }], + } as unknown as TextDocumentChangeEvent); + + // Allow debouncer to fire + await vi.advanceTimersByTimeAsync(150); + + expect(session.writtenPlans).toEqual(['updated plan content']); + + // Resolve the question to complete the handler + resolveQuestion(undefined); + const result = await promise; + expect(result.approved).toBe(false); + }); + + it('does not sync when document is still dirty', async () => { + const planUri = URI.file('/session/plan.md'); + const dirtyDoc = { + uri: planUri, + isDirty: true, + getText: () => 'dirty content', + }; + + let resolveQuestion!: (answer: IQuestionAnswer | undefined) => void; + questionHandler.askUserQuestion = (_q, _t, _ct) => { + return new Promise(resolve => { resolveQuestion = resolve; }); + }; + + const promise = handleExitPlanMode( + makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN, + workspaceService, logService, questionHandler, CANCEL_TOKEN, + ); + + workspaceService.didChangeTextDocumentEmitter.fire({ + document: dirtyDoc, + contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }], + } as unknown as TextDocumentChangeEvent); + + await vi.advanceTimersByTimeAsync(150); + + expect(session.writtenPlans).toEqual([]); + + resolveQuestion(undefined); + await promise; + }); + + it('ignores document changes for unrelated files', async () => { + const otherUri = URI.file('/other/file.md'); + const otherDoc = { + uri: otherUri, + isDirty: false, + getText: () => 'other content', + }; + + let resolveQuestion!: (answer: IQuestionAnswer | undefined) => void; + questionHandler.askUserQuestion = (_q, _t, _ct) => { + return new Promise(resolve => { resolveQuestion = resolve; }); + }; + + const promise = handleExitPlanMode( + makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN, + workspaceService, logService, questionHandler, CANCEL_TOKEN, + ); + + workspaceService.didChangeTextDocumentEmitter.fire({ + document: otherDoc, + contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }], + } as unknown as TextDocumentChangeEvent); + + await vi.advanceTimersByTimeAsync(150); + + expect(session.writtenPlans).toEqual([]); + + resolveQuestion(undefined); + await promise; + }); + + it('does not create monitor when no plan path', async () => { + session.planPath = undefined; + questionHandler.answer = { selected: ['interactive'], freeText: null, skipped: false }; + + const result = await handleExitPlanMode( + makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN, + workspaceService, logService, questionHandler, CANCEL_TOKEN, + ); + + // Should complete without errors even with no plan path + expect(result.approved).toBe(true); + }); + }); +});