Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { Session } from '@github/copilot/sdk';6import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';7import type * as vscode from 'vscode';8import type { CancellationToken, ChatParticipantToolToken, TextDocumentChangeEvent } from 'vscode';9import { IChatEndpoint } from '../../../../../platform/networking/common/networking';10import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';11import { Emitter } from '../../../../../util/vs/base/common/event';12import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';13import { constObservable, IObservable } from '../../../../../util/vs/base/common/observableInternal';14import { URI } from '../../../../../util/vs/base/common/uri';15import { LanguageModelTextPart } from '../../../../../vscodeTypes';16import { ToolName } from '../../../../tools/common/toolNames';17import { ICopilotTool } from '../../../../tools/common/toolsRegistry';18import { IOnWillInvokeToolEvent, IToolsService, IToolValidationResult } from '../../../../tools/common/toolsService';19import { handleExitPlanMode, type ExitPlanModeEventData, type ExitPlanModeResponse } from '../exitPlanModeHandler';2021// ---------- helpers / mocks ----------2223function makeEvent(overrides: Partial<ExitPlanModeEventData> = {}): ExitPlanModeEventData {24return {25requestId: 'req-1',26summary: 'Test plan summary',27actions: ['autopilot', 'interactive', 'exit_only'],28recommendedAction: 'autopilot',29...overrides,30};31}3233class StubSession {34public writtenPlans: string[] = [];35constructor(public planPath: string | undefined = '/session/plan.md') { }36getPlanPath(): string | undefined { return this.planPath; }37async writePlan(content: string): Promise<void> { this.writtenPlans.push(content); }38}3940class FakeToolsService implements IToolsService {41readonly _serviceBrand: undefined;4243private readonly _onWillInvokeTool = new Emitter<IOnWillInvokeToolEvent>();44readonly onWillInvokeTool = this._onWillInvokeTool.event;45readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation> = [];46readonly copilotTools = new Map<ToolName, ICopilotTool<unknown>>();47modelSpecificTools: IObservable<{ definition: vscode.LanguageModelToolDefinition; tool: ICopilotTool<unknown> }[]> = constObservable([]);4849private _result: vscode.LanguageModelToolResult2 = { content: [] };50invokeToolCalls: Array<{ name: string; input: unknown }> = [];5152setResult(answer: { action?: string; rejected: boolean; feedback?: string }): void {53this._result = {54content: [new LanguageModelTextPart(JSON.stringify(answer))]55};56}5758setEmptyResult(): void {59this._result = { content: [] };60}6162async invokeTool(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>): Promise<vscode.LanguageModelToolResult2> {63this.invokeToolCalls.push({ name, input: options.input });64return this._result;65}6667invokeToolWithEndpoint(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>, _endpoint: IChatEndpoint | undefined): Thenable<vscode.LanguageModelToolResult2> {68return this.invokeTool(name, options);69}7071getCopilotTool(): ICopilotTool<unknown> | undefined { return undefined; }72getTool(): vscode.LanguageModelToolInformation | undefined { return undefined; }73getToolByToolReferenceName(): vscode.LanguageModelToolInformation | undefined { return undefined; }74validateToolInput(): IToolValidationResult { return { inputObj: {} }; }75validateToolName(): string | undefined { return undefined; }76getEnabledTools(): vscode.LanguageModelToolInformation[] { return []; }77}7879function stubLogService() {80return {81_serviceBrand: undefined,82trace: vi.fn(),83debug: vi.fn(),84info: vi.fn(),85warn: vi.fn(),86error: vi.fn(),87} as any;88}8990const FAKE_TOKEN = {} as ChatParticipantToolToken;91const CANCEL_TOKEN: CancellationToken = { isCancellationRequested: false, onCancellationRequested: new Emitter<void>().event };9293// ---------- tests ----------9495describe('handleExitPlanMode', () => {96const disposables = new DisposableStore();97let session: StubSession;98let logService: ReturnType<typeof stubLogService>;99let workspaceService: NullWorkspaceService;100let toolService: FakeToolsService;101102beforeEach(() => {103session = new StubSession();104logService = stubLogService();105workspaceService = disposables.add(new NullWorkspaceService());106toolService = new FakeToolsService();107});108109afterEach(() => {110disposables.clear();111});112113// ---- autopilot ----114115describe('autopilot mode', () => {116it('auto-approves with recommended action when it is available', async () => {117const event = makeEvent({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'interactive' });118const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);119expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'interactive', autoApproveEdits: true });120});121122it('falls back to first available action in priority order when no recommended', async () => {123const event = makeEvent({ actions: ['interactive', 'exit_only'], recommendedAction: '' });124const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);125expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'interactive', autoApproveEdits: undefined });126});127128it('prefers autopilot over other actions in fallback order', async () => {129const event = makeEvent({ actions: ['exit_only', 'autopilot'], recommendedAction: '' });130const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);131expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot', autoApproveEdits: true });132});133134it('prefers autopilot_fleet second in fallback order', async () => {135const event = makeEvent({ actions: ['exit_only', 'autopilot_fleet', 'interactive'], recommendedAction: '' });136const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);137expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot_fleet', autoApproveEdits: true });138});139140it('returns approved with autoApproveEdits when no actions available', async () => {141const event = makeEvent({ actions: [], recommendedAction: '' });142const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);143expect(result).toEqual<ExitPlanModeResponse>({ approved: true, autoApproveEdits: true });144});145146it('sets autoApproveEdits only for autopilot and autopilot_fleet', async () => {147const event1 = makeEvent({ actions: ['exit_only'], recommendedAction: '' });148const r1 = await handleExitPlanMode(event1, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);149expect(r1.autoApproveEdits).toBeUndefined();150151const event2 = makeEvent({ actions: ['interactive'], recommendedAction: '' });152const r2 = await handleExitPlanMode(event2, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);153expect(r2.autoApproveEdits).toBeUndefined();154});155});156157// ---- no tool invocation token ----158159describe('missing toolInvocationToken', () => {160it('returns not approved when no token', async () => {161const event = makeEvent();162const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', undefined, workspaceService, logService, toolService, CANCEL_TOKEN);163expect(result).toEqual<ExitPlanModeResponse>({ approved: false });164});165});166167// ---- interactive mode ----168169describe('interactive mode', () => {170it('returns not approved when tool returns empty result', async () => {171toolService.setEmptyResult();172const event = makeEvent();173const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);174expect(result).toEqual<ExitPlanModeResponse>({ approved: false });175});176177it('returns not approved when user rejects the plan', async () => {178toolService.setResult({ rejected: true });179const event = makeEvent();180const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);181expect(result).toEqual<ExitPlanModeResponse>({ approved: false });182});183184it('returns feedback when user provides freeform text', async () => {185toolService.setResult({ rejected: false, feedback: 'I want changes to the plan' });186const event = makeEvent();187const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);188expect(result).toEqual<ExitPlanModeResponse>({ approved: false, feedback: 'I want changes to the plan', selectedAction: undefined });189});190191it('returns feedback with selected action', async () => {192toolService.setResult({ rejected: false, action: 'interactive', feedback: 'needs more detail' });193const event = makeEvent();194const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);195expect(result).toEqual<ExitPlanModeResponse>({ approved: false, feedback: 'needs more detail', selectedAction: 'interactive' });196});197198it('returns approved with selected action mapped from label', async () => {199toolService.setResult({ rejected: false, action: 'Implement with Autopilot' });200const event = makeEvent();201const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);202expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot', autoApproveEdits: undefined });203});204205it('maps "Approve Plan Only" label to exit_only', async () => {206toolService.setResult({ rejected: false, action: 'Approve Plan Only' });207const event = makeEvent();208const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);209expect(result.selectedAction).toBe('exit_only');210});211212it('sets autoApproveEdits when permissionLevel is autoApprove', async () => {213toolService.setResult({ rejected: false, action: 'Implement Plan' });214const event = makeEvent();215const result = await handleExitPlanMode(event, session as unknown as Session, 'autoApprove', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);216expect(result.autoApproveEdits).toBe(true);217});218219it('does not set autoApproveEdits when permissionLevel is interactive', async () => {220toolService.setResult({ rejected: false, action: 'Implement Plan' });221const event = makeEvent();222const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);223expect(result.autoApproveEdits).toBeUndefined();224});225226it('passes actions with labels and recommended flag to tool', async () => {227toolService.setResult({ rejected: false, action: 'Implement Plan' });228const event = makeEvent({ actions: ['autopilot', 'exit_only'], recommendedAction: 'exit_only' });229await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);230const call = toolService.invokeToolCalls[0];231expect(call.name).toBe('vscode_reviewPlan');232const input = call.input as any;233expect(input.actions).toHaveLength(2);234expect(input.actions[0]).toEqual(expect.objectContaining({ label: 'Implement with Autopilot', default: false }));235expect(input.actions[1]).toEqual(expect.objectContaining({ label: 'Approve Plan Only', default: true }));236});237238it('includes plan path in tool input when plan path exists', async () => {239session.planPath = '/session/plan.md';240toolService.setResult({ rejected: false, action: 'Interactive' });241const event = makeEvent();242await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);243const input = toolService.invokeToolCalls[0].input as any;244expect(input.plan).toBe('file:///session/plan.md');245});246247it('passes undefined plan when no plan path', async () => {248session.planPath = undefined;249toolService.setResult({ rejected: false, action: 'Interactive' });250const event = makeEvent();251await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);252const input = toolService.invokeToolCalls[0].input as any;253expect(input.plan).toBeUndefined();254});255256it('enables feedback via canProvideFeedback', async () => {257toolService.setEmptyResult();258const event = makeEvent();259await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);260const input = toolService.invokeToolCalls[0].input as any;261expect(input.canProvideFeedback).toBe(true);262});263});264265// ---- plan file monitoring ----266267describe('plan file monitoring', () => {268beforeEach(() => {269vi.useFakeTimers();270});271272afterEach(() => {273vi.useRealTimers();274});275276it('syncs saved plan changes to SDK session', async () => {277const planUri = URI.file('/session/plan.md');278const savedDoc = {279uri: planUri,280isDirty: false,281getText: () => 'updated plan content',282};283284// Set up a deferred tool invocation so we can fire document changes while waiting285let resolveInvokeTool!: (result: vscode.LanguageModelToolResult2) => void;286toolService.invokeTool = ((_name: string, _options: vscode.LanguageModelToolInvocationOptions<unknown>) => {287return new Promise<vscode.LanguageModelToolResult2>(resolve => { resolveInvokeTool = resolve; });288});289290const promise = handleExitPlanMode(291makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,292workspaceService, logService, toolService, CANCEL_TOKEN,293);294295// Simulate a saved document change296workspaceService.didChangeTextDocumentEmitter.fire({297document: savedDoc,298contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }],299} as unknown as TextDocumentChangeEvent);300301// Allow debouncer to fire302await vi.advanceTimersByTimeAsync(150);303304expect(session.writtenPlans).toEqual(['updated plan content']);305306// Resolve the tool invocation to complete the handler (empty result → not approved)307resolveInvokeTool({ content: [] });308const result = await promise;309expect(result.approved).toBe(false);310});311312it('does not sync when document is still dirty', async () => {313const planUri = URI.file('/session/plan.md');314const dirtyDoc = {315uri: planUri,316isDirty: true,317getText: () => 'dirty content',318};319320let resolveInvokeTool!: (result: vscode.LanguageModelToolResult2) => void;321toolService.invokeTool = ((_name: string, _options: vscode.LanguageModelToolInvocationOptions<unknown>) => {322return new Promise<vscode.LanguageModelToolResult2>(resolve => { resolveInvokeTool = resolve; });323});324325const promise = handleExitPlanMode(326makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,327workspaceService, logService, toolService, CANCEL_TOKEN,328);329330workspaceService.didChangeTextDocumentEmitter.fire({331document: dirtyDoc,332contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }],333} as unknown as TextDocumentChangeEvent);334335await vi.advanceTimersByTimeAsync(150);336337expect(session.writtenPlans).toEqual([]);338339resolveInvokeTool({ content: [] });340await promise;341});342343it('ignores document changes for unrelated files', async () => {344const otherUri = URI.file('/other/file.md');345const otherDoc = {346uri: otherUri,347isDirty: false,348getText: () => 'other content',349};350351let resolveInvokeTool!: (result: vscode.LanguageModelToolResult2) => void;352toolService.invokeTool = ((_name: string, _options: vscode.LanguageModelToolInvocationOptions<unknown>) => {353return new Promise<vscode.LanguageModelToolResult2>(resolve => { resolveInvokeTool = resolve; });354});355356const promise = handleExitPlanMode(357makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,358workspaceService, logService, toolService, CANCEL_TOKEN,359);360361workspaceService.didChangeTextDocumentEmitter.fire({362document: otherDoc,363contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }],364} as unknown as TextDocumentChangeEvent);365366await vi.advanceTimersByTimeAsync(150);367368expect(session.writtenPlans).toEqual([]);369370resolveInvokeTool({ content: [] });371await promise;372});373374it('does not create monitor when no plan path', async () => {375session.planPath = undefined;376toolService.setResult({ rejected: false, action: 'Interactive' });377378const result = await handleExitPlanMode(379makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,380workspaceService, logService, toolService, CANCEL_TOKEN,381);382383// Should complete without errors even with no plan path384expect(result.approved).toBe(true);385});386});387});388389390