Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { Session } from '@github/copilot/sdk';
7
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
import type * as vscode from 'vscode';
9
import type { CancellationToken, ChatParticipantToolToken, TextDocumentChangeEvent } from 'vscode';
10
import { IChatEndpoint } from '../../../../../platform/networking/common/networking';
11
import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
12
import { Emitter } from '../../../../../util/vs/base/common/event';
13
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
14
import { constObservable, IObservable } from '../../../../../util/vs/base/common/observableInternal';
15
import { URI } from '../../../../../util/vs/base/common/uri';
16
import { LanguageModelTextPart } from '../../../../../vscodeTypes';
17
import { ToolName } from '../../../../tools/common/toolNames';
18
import { ICopilotTool } from '../../../../tools/common/toolsRegistry';
19
import { IOnWillInvokeToolEvent, IToolsService, IToolValidationResult } from '../../../../tools/common/toolsService';
20
import { handleExitPlanMode, type ExitPlanModeEventData, type ExitPlanModeResponse } from '../exitPlanModeHandler';
21
22
// ---------- helpers / mocks ----------
23
24
function makeEvent(overrides: Partial<ExitPlanModeEventData> = {}): ExitPlanModeEventData {
25
return {
26
requestId: 'req-1',
27
summary: 'Test plan summary',
28
actions: ['autopilot', 'interactive', 'exit_only'],
29
recommendedAction: 'autopilot',
30
...overrides,
31
};
32
}
33
34
class StubSession {
35
public writtenPlans: string[] = [];
36
constructor(public planPath: string | undefined = '/session/plan.md') { }
37
getPlanPath(): string | undefined { return this.planPath; }
38
async writePlan(content: string): Promise<void> { this.writtenPlans.push(content); }
39
}
40
41
class FakeToolsService implements IToolsService {
42
readonly _serviceBrand: undefined;
43
44
private readonly _onWillInvokeTool = new Emitter<IOnWillInvokeToolEvent>();
45
readonly onWillInvokeTool = this._onWillInvokeTool.event;
46
readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation> = [];
47
readonly copilotTools = new Map<ToolName, ICopilotTool<unknown>>();
48
modelSpecificTools: IObservable<{ definition: vscode.LanguageModelToolDefinition; tool: ICopilotTool<unknown> }[]> = constObservable([]);
49
50
private _result: vscode.LanguageModelToolResult2 = { content: [] };
51
invokeToolCalls: Array<{ name: string; input: unknown }> = [];
52
53
setResult(answer: { action?: string; rejected: boolean; feedback?: string }): void {
54
this._result = {
55
content: [new LanguageModelTextPart(JSON.stringify(answer))]
56
};
57
}
58
59
setEmptyResult(): void {
60
this._result = { content: [] };
61
}
62
63
async invokeTool(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>): Promise<vscode.LanguageModelToolResult2> {
64
this.invokeToolCalls.push({ name, input: options.input });
65
return this._result;
66
}
67
68
invokeToolWithEndpoint(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>, _endpoint: IChatEndpoint | undefined): Thenable<vscode.LanguageModelToolResult2> {
69
return this.invokeTool(name, options);
70
}
71
72
getCopilotTool(): ICopilotTool<unknown> | undefined { return undefined; }
73
getTool(): vscode.LanguageModelToolInformation | undefined { return undefined; }
74
getToolByToolReferenceName(): vscode.LanguageModelToolInformation | undefined { return undefined; }
75
validateToolInput(): IToolValidationResult { return { inputObj: {} }; }
76
validateToolName(): string | undefined { return undefined; }
77
getEnabledTools(): vscode.LanguageModelToolInformation[] { return []; }
78
}
79
80
function stubLogService() {
81
return {
82
_serviceBrand: undefined,
83
trace: vi.fn(),
84
debug: vi.fn(),
85
info: vi.fn(),
86
warn: vi.fn(),
87
error: vi.fn(),
88
} as any;
89
}
90
91
const FAKE_TOKEN = {} as ChatParticipantToolToken;
92
const CANCEL_TOKEN: CancellationToken = { isCancellationRequested: false, onCancellationRequested: new Emitter<void>().event };
93
94
// ---------- tests ----------
95
96
describe('handleExitPlanMode', () => {
97
const disposables = new DisposableStore();
98
let session: StubSession;
99
let logService: ReturnType<typeof stubLogService>;
100
let workspaceService: NullWorkspaceService;
101
let toolService: FakeToolsService;
102
103
beforeEach(() => {
104
session = new StubSession();
105
logService = stubLogService();
106
workspaceService = disposables.add(new NullWorkspaceService());
107
toolService = new FakeToolsService();
108
});
109
110
afterEach(() => {
111
disposables.clear();
112
});
113
114
// ---- autopilot ----
115
116
describe('autopilot mode', () => {
117
it('auto-approves with recommended action when it is available', async () => {
118
const event = makeEvent({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'interactive' });
119
const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
120
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'interactive', autoApproveEdits: true });
121
});
122
123
it('falls back to first available action in priority order when no recommended', async () => {
124
const event = makeEvent({ actions: ['interactive', 'exit_only'], recommendedAction: '' });
125
const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
126
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'interactive', autoApproveEdits: undefined });
127
});
128
129
it('prefers autopilot over other actions in fallback order', async () => {
130
const event = makeEvent({ actions: ['exit_only', 'autopilot'], recommendedAction: '' });
131
const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
132
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot', autoApproveEdits: true });
133
});
134
135
it('prefers autopilot_fleet second in fallback order', async () => {
136
const event = makeEvent({ actions: ['exit_only', 'autopilot_fleet', 'interactive'], recommendedAction: '' });
137
const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
138
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot_fleet', autoApproveEdits: true });
139
});
140
141
it('returns approved with autoApproveEdits when no actions available', async () => {
142
const event = makeEvent({ actions: [], recommendedAction: '' });
143
const result = await handleExitPlanMode(event, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
144
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, autoApproveEdits: true });
145
});
146
147
it('sets autoApproveEdits only for autopilot and autopilot_fleet', async () => {
148
const event1 = makeEvent({ actions: ['exit_only'], recommendedAction: '' });
149
const r1 = await handleExitPlanMode(event1, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
150
expect(r1.autoApproveEdits).toBeUndefined();
151
152
const event2 = makeEvent({ actions: ['interactive'], recommendedAction: '' });
153
const r2 = await handleExitPlanMode(event2, session as unknown as Session, 'autopilot', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
154
expect(r2.autoApproveEdits).toBeUndefined();
155
});
156
});
157
158
// ---- no tool invocation token ----
159
160
describe('missing toolInvocationToken', () => {
161
it('returns not approved when no token', async () => {
162
const event = makeEvent();
163
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', undefined, workspaceService, logService, toolService, CANCEL_TOKEN);
164
expect(result).toEqual<ExitPlanModeResponse>({ approved: false });
165
});
166
});
167
168
// ---- interactive mode ----
169
170
describe('interactive mode', () => {
171
it('returns not approved when tool returns empty result', async () => {
172
toolService.setEmptyResult();
173
const event = makeEvent();
174
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
175
expect(result).toEqual<ExitPlanModeResponse>({ approved: false });
176
});
177
178
it('returns not approved when user rejects the plan', async () => {
179
toolService.setResult({ rejected: true });
180
const event = makeEvent();
181
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
182
expect(result).toEqual<ExitPlanModeResponse>({ approved: false });
183
});
184
185
it('returns feedback when user provides freeform text', async () => {
186
toolService.setResult({ rejected: false, feedback: 'I want changes to the plan' });
187
const event = makeEvent();
188
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
189
expect(result).toEqual<ExitPlanModeResponse>({ approved: false, feedback: 'I want changes to the plan', selectedAction: undefined });
190
});
191
192
it('returns feedback with selected action', async () => {
193
toolService.setResult({ rejected: false, action: 'interactive', feedback: 'needs more detail' });
194
const event = makeEvent();
195
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
196
expect(result).toEqual<ExitPlanModeResponse>({ approved: false, feedback: 'needs more detail', selectedAction: 'interactive' });
197
});
198
199
it('returns approved with selected action mapped from label', async () => {
200
toolService.setResult({ rejected: false, action: 'Implement with Autopilot' });
201
const event = makeEvent();
202
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
203
expect(result).toEqual<ExitPlanModeResponse>({ approved: true, selectedAction: 'autopilot', autoApproveEdits: undefined });
204
});
205
206
it('maps "Approve Plan Only" label to exit_only', async () => {
207
toolService.setResult({ rejected: false, action: 'Approve Plan Only' });
208
const event = makeEvent();
209
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
210
expect(result.selectedAction).toBe('exit_only');
211
});
212
213
it('sets autoApproveEdits when permissionLevel is autoApprove', async () => {
214
toolService.setResult({ rejected: false, action: 'Implement Plan' });
215
const event = makeEvent();
216
const result = await handleExitPlanMode(event, session as unknown as Session, 'autoApprove', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
217
expect(result.autoApproveEdits).toBe(true);
218
});
219
220
it('does not set autoApproveEdits when permissionLevel is interactive', async () => {
221
toolService.setResult({ rejected: false, action: 'Implement Plan' });
222
const event = makeEvent();
223
const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
224
expect(result.autoApproveEdits).toBeUndefined();
225
});
226
227
it('passes actions with labels and recommended flag to tool', async () => {
228
toolService.setResult({ rejected: false, action: 'Implement Plan' });
229
const event = makeEvent({ actions: ['autopilot', 'exit_only'], recommendedAction: 'exit_only' });
230
await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
231
const call = toolService.invokeToolCalls[0];
232
expect(call.name).toBe('vscode_reviewPlan');
233
const input = call.input as any;
234
expect(input.actions).toHaveLength(2);
235
expect(input.actions[0]).toEqual(expect.objectContaining({ label: 'Implement with Autopilot', default: false }));
236
expect(input.actions[1]).toEqual(expect.objectContaining({ label: 'Approve Plan Only', default: true }));
237
});
238
239
it('includes plan path in tool input when plan path exists', async () => {
240
session.planPath = '/session/plan.md';
241
toolService.setResult({ rejected: false, action: 'Interactive' });
242
const event = makeEvent();
243
await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
244
const input = toolService.invokeToolCalls[0].input as any;
245
expect(input.plan).toBe('file:///session/plan.md');
246
});
247
248
it('passes undefined plan when no plan path', async () => {
249
session.planPath = undefined;
250
toolService.setResult({ rejected: false, action: 'Interactive' });
251
const event = makeEvent();
252
await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
253
const input = toolService.invokeToolCalls[0].input as any;
254
expect(input.plan).toBeUndefined();
255
});
256
257
it('enables feedback via canProvideFeedback', async () => {
258
toolService.setEmptyResult();
259
const event = makeEvent();
260
await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN);
261
const input = toolService.invokeToolCalls[0].input as any;
262
expect(input.canProvideFeedback).toBe(true);
263
});
264
});
265
266
// ---- plan file monitoring ----
267
268
describe('plan file monitoring', () => {
269
beforeEach(() => {
270
vi.useFakeTimers();
271
});
272
273
afterEach(() => {
274
vi.useRealTimers();
275
});
276
277
it('syncs saved plan changes to SDK session', async () => {
278
const planUri = URI.file('/session/plan.md');
279
const savedDoc = {
280
uri: planUri,
281
isDirty: false,
282
getText: () => 'updated plan content',
283
};
284
285
// Set up a deferred tool invocation so we can fire document changes while waiting
286
let resolveInvokeTool!: (result: vscode.LanguageModelToolResult2) => void;
287
toolService.invokeTool = ((_name: string, _options: vscode.LanguageModelToolInvocationOptions<unknown>) => {
288
return new Promise<vscode.LanguageModelToolResult2>(resolve => { resolveInvokeTool = resolve; });
289
});
290
291
const promise = handleExitPlanMode(
292
makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,
293
workspaceService, logService, toolService, CANCEL_TOKEN,
294
);
295
296
// Simulate a saved document change
297
workspaceService.didChangeTextDocumentEmitter.fire({
298
document: savedDoc,
299
contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }],
300
} as unknown as TextDocumentChangeEvent);
301
302
// Allow debouncer to fire
303
await vi.advanceTimersByTimeAsync(150);
304
305
expect(session.writtenPlans).toEqual(['updated plan content']);
306
307
// Resolve the tool invocation to complete the handler (empty result → not approved)
308
resolveInvokeTool({ content: [] });
309
const result = await promise;
310
expect(result.approved).toBe(false);
311
});
312
313
it('does not sync when document is still dirty', async () => {
314
const planUri = URI.file('/session/plan.md');
315
const dirtyDoc = {
316
uri: planUri,
317
isDirty: true,
318
getText: () => 'dirty content',
319
};
320
321
let resolveInvokeTool!: (result: vscode.LanguageModelToolResult2) => void;
322
toolService.invokeTool = ((_name: string, _options: vscode.LanguageModelToolInvocationOptions<unknown>) => {
323
return new Promise<vscode.LanguageModelToolResult2>(resolve => { resolveInvokeTool = resolve; });
324
});
325
326
const promise = handleExitPlanMode(
327
makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,
328
workspaceService, logService, toolService, CANCEL_TOKEN,
329
);
330
331
workspaceService.didChangeTextDocumentEmitter.fire({
332
document: dirtyDoc,
333
contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }],
334
} as unknown as TextDocumentChangeEvent);
335
336
await vi.advanceTimersByTimeAsync(150);
337
338
expect(session.writtenPlans).toEqual([]);
339
340
resolveInvokeTool({ content: [] });
341
await promise;
342
});
343
344
it('ignores document changes for unrelated files', async () => {
345
const otherUri = URI.file('/other/file.md');
346
const otherDoc = {
347
uri: otherUri,
348
isDirty: false,
349
getText: () => 'other content',
350
};
351
352
let resolveInvokeTool!: (result: vscode.LanguageModelToolResult2) => void;
353
toolService.invokeTool = ((_name: string, _options: vscode.LanguageModelToolInvocationOptions<unknown>) => {
354
return new Promise<vscode.LanguageModelToolResult2>(resolve => { resolveInvokeTool = resolve; });
355
});
356
357
const promise = handleExitPlanMode(
358
makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,
359
workspaceService, logService, toolService, CANCEL_TOKEN,
360
);
361
362
workspaceService.didChangeTextDocumentEmitter.fire({
363
document: otherDoc,
364
contentChanges: [{ range: {} as any, rangeOffset: 0, rangeLength: 0, text: 'x' }],
365
} as unknown as TextDocumentChangeEvent);
366
367
await vi.advanceTimersByTimeAsync(150);
368
369
expect(session.writtenPlans).toEqual([]);
370
371
resolveInvokeTool({ content: [] });
372
await promise;
373
});
374
375
it('does not create monitor when no plan path', async () => {
376
session.planPath = undefined;
377
toolService.setResult({ rejected: false, action: 'Interactive' });
378
379
const result = await handleExitPlanMode(
380
makeEvent(), session as unknown as Session, 'interactive', FAKE_TOKEN,
381
workspaceService, logService, toolService, CANCEL_TOKEN,
382
);
383
384
// Should complete without errors even with no plan path
385
expect(result.approved).toBe(true);
386
});
387
});
388
});
389
390