Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts
13405 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 { beforeEach, describe, expect, it, vi } from 'vitest';
7
import type * as vscode from 'vscode';
8
import { ILogService } from '../../../../platform/log/common/logService';
9
import { mock } from '../../../../util/common/test/simpleMock';
10
import { Event } from '../../../../util/vs/base/common/event';
11
import { URI } from '../../../../util/vs/base/common/uri';
12
import { ChatSessionStatus } from '../../../../vscodeTypes';
13
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
14
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
15
import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService';
16
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
17
import { IWorkspaceInfo } from '../../common/workspaceInfo';
18
import { IPullRequestDetectionService } from '../pullRequestDetectionService';
19
import { SessionCompletionInfo, SessionRequestLifecycle } from '../sessionRequestLifecycle';
20
21
// ─── Test Helpers ────────────────────────────────────────────────
22
23
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
24
declare readonly _serviceBrand: undefined;
25
override handleRequestCompleted = vi.fn(async () => { });
26
override setWorktreeProperties = vi.fn(async () => { });
27
}
28
29
class TestCheckpointService extends mock<IChatSessionWorktreeCheckpointService>() {
30
declare readonly _serviceBrand: undefined;
31
override handleRequest = vi.fn(async () => { });
32
override handleRequestCompleted = vi.fn(async () => { });
33
}
34
35
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
36
declare readonly _serviceBrand: undefined;
37
override handleRequestCompleted = vi.fn(async () => { });
38
override trackSessionWorkspaceFolder = vi.fn(async () => { });
39
}
40
41
class TestPrDetectionService extends mock<IPullRequestDetectionService>() {
42
declare readonly _serviceBrand: undefined;
43
override onDidDetectPullRequest = Event.None;
44
override handlePullRequestCreated = vi.fn();
45
}
46
47
class TestMetadataStore extends mock<IChatSessionMetadataStore>() {
48
declare readonly _serviceBrand: undefined;
49
override updateRequestDetails = vi.fn(async () => { });
50
}
51
52
class TestLogService extends mock<ILogService>() {
53
declare readonly _serviceBrand: undefined;
54
override error = vi.fn();
55
}
56
57
function makeRequest(id: string = 'req-1'): vscode.ChatRequest {
58
return { id } as unknown as vscode.ChatRequest;
59
}
60
61
function makeSession(overrides?: Partial<SessionCompletionInfo>): SessionCompletionInfo {
62
return {
63
status: ChatSessionStatus.Completed,
64
workspace: {
65
folder: URI.file('/workspace') as unknown as vscode.Uri,
66
repository: undefined,
67
repositoryProperties: undefined,
68
worktree: undefined,
69
worktreeProperties: undefined,
70
},
71
createdPullRequestUrl: undefined,
72
...overrides,
73
};
74
}
75
76
function makeIsolatedSession(overrides?: Partial<SessionCompletionInfo>): SessionCompletionInfo {
77
return makeSession({
78
workspace: {
79
folder: URI.file('/workspace') as unknown as vscode.Uri,
80
repository: URI.file('/repo') as unknown as vscode.Uri,
81
repositoryProperties: undefined,
82
worktree: URI.file('/worktree') as unknown as vscode.Uri,
83
worktreeProperties: {
84
version: 2,
85
baseCommit: 'abc',
86
baseBranchName: 'main',
87
branchName: 'copilot/test',
88
repositoryPath: '/repo',
89
worktreePath: '/worktree',
90
},
91
},
92
...overrides,
93
});
94
}
95
96
function makeToken(cancelled: boolean = false): vscode.CancellationToken {
97
return { isCancellationRequested: cancelled, onCancellationRequested: vi.fn() } as unknown as vscode.CancellationToken;
98
}
99
100
function makeWorkspace(overrides?: Partial<IWorkspaceInfo>): IWorkspaceInfo {
101
return {
102
folder: URI.file('/workspace') as unknown as vscode.Uri,
103
repository: undefined,
104
repositoryProperties: undefined,
105
worktree: undefined,
106
worktreeProperties: undefined,
107
...overrides,
108
};
109
}
110
111
function makeIsolatedWorkspace(): IWorkspaceInfo {
112
return makeWorkspace({
113
repository: URI.file('/repo') as unknown as vscode.Uri,
114
worktree: URI.file('/worktree') as unknown as vscode.Uri,
115
worktreeProperties: {
116
version: 2,
117
baseCommit: 'abc',
118
baseBranchName: 'main',
119
branchName: 'copilot/test',
120
repositoryPath: '/repo',
121
worktreePath: '/worktree',
122
},
123
});
124
}
125
126
// ─── Tests ───────────────────────────────────────────────────────
127
128
describe('SessionRequestLifecycle', () => {
129
let worktreeService: TestWorktreeService;
130
let checkpointService: TestCheckpointService;
131
let workspaceFolderService: TestWorkspaceFolderService;
132
let prDetectionService: TestPrDetectionService;
133
let metadataStore: TestMetadataStore;
134
let logService: TestLogService;
135
let handler: SessionRequestLifecycle;
136
137
beforeEach(() => {
138
vi.restoreAllMocks();
139
worktreeService = new TestWorktreeService();
140
checkpointService = new TestCheckpointService();
141
workspaceFolderService = new TestWorkspaceFolderService();
142
prDetectionService = new TestPrDetectionService();
143
metadataStore = new TestMetadataStore();
144
logService = new TestLogService();
145
handler = new SessionRequestLifecycle(
146
worktreeService,
147
checkpointService,
148
workspaceFolderService,
149
prDetectionService,
150
metadataStore,
151
logService,
152
);
153
});
154
155
describe('startRequest', () => {
156
it('creates baseline checkpoint on first request', async () => {
157
const request = makeRequest();
158
await handler.startRequest('session-1', request, true, makeWorkspace());
159
expect(checkpointService.handleRequest).toHaveBeenCalledWith('session-1');
160
});
161
162
it('skips baseline checkpoint on subsequent requests', async () => {
163
const request = makeRequest();
164
await handler.startRequest('session-1', request, false, makeWorkspace());
165
expect(checkpointService.handleRequest).not.toHaveBeenCalled();
166
});
167
168
it('records request metadata with modeInstructions', async () => {
169
const request = makeRequest();
170
(request as any).modeInstructions2 = {
171
name: 'test',
172
content: 'instructions',
173
};
174
await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent');
175
176
expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(
177
'session-1',
178
[{
179
vscodeRequestId: 'req-1',
180
agentId: 'test-agent',
181
modeInstructions: expect.objectContaining({ name: 'test', content: 'instructions' }),
182
}]
183
);
184
});
185
186
it('records metadata without modeInstructions when request has no modeInstructions2', async () => {
187
const request = makeRequest();
188
await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent');
189
190
expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(
191
'session-1',
192
[{
193
vscodeRequestId: 'req-1',
194
agentId: 'test-agent',
195
modeInstructions: undefined,
196
}]
197
);
198
});
199
200
it('sets worktree properties on first request with worktree', async () => {
201
const workspace = makeIsolatedWorkspace();
202
await handler.startRequest('session-1', makeRequest(), true, workspace);
203
204
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
205
'session-1',
206
expect.objectContaining({ branchName: 'copilot/test' })
207
);
208
});
209
210
it('does not set worktree properties on subsequent requests', async () => {
211
const workspace = makeIsolatedWorkspace();
212
await handler.startRequest('session-1', makeRequest(), false, workspace);
213
214
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
215
});
216
217
it('tracks workspace folder for non-isolated session on first request', async () => {
218
const workspace = makeWorkspace();
219
await handler.startRequest('session-1', makeRequest(), true, workspace);
220
221
expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();
222
});
223
224
it('does not track workspace folder for isolated session', async () => {
225
const workspace = makeIsolatedWorkspace();
226
await handler.startRequest('session-1', makeRequest(), true, workspace);
227
228
expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();
229
});
230
});
231
232
describe('endRequest', () => {
233
it('commits worktree changes for isolated session', async () => {
234
const request = makeRequest();
235
const session = makeIsolatedSession();
236
237
await handler.startRequest('session-1', request, false, makeWorkspace());
238
await handler.endRequest('session-1', request, session, makeToken());
239
240
expect(worktreeService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
241
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
242
expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-1');
243
});
244
245
it('stages workspace changes for non-isolated session with working directory', async () => {
246
const request = makeRequest();
247
const session = makeSession(); // non-isolated, has folder
248
249
await handler.startRequest('session-1', request, false, makeWorkspace());
250
await handler.endRequest('session-1', request, session, makeToken());
251
252
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
253
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
254
expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-1');
255
});
256
257
it('skips commit/stage when session status is not Completed', async () => {
258
const request = makeRequest();
259
const session = makeSession({ status: ChatSessionStatus.InProgress });
260
261
await handler.startRequest('session-1', request, false, makeWorkspace());
262
await handler.endRequest('session-1', request, session, makeToken());
263
264
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
265
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
266
expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();
267
});
268
269
it('skips commit/stage when session status is undefined', async () => {
270
const request = makeRequest();
271
const session = makeSession({ status: undefined });
272
273
await handler.startRequest('session-1', request, false, makeWorkspace());
274
await handler.endRequest('session-1', request, session, makeToken());
275
276
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
277
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
278
});
279
280
it('skips workspace commit when no working directory', async () => {
281
const request = makeRequest();
282
const session = makeSession({
283
workspace: {
284
folder: undefined,
285
repository: undefined,
286
repositoryProperties: undefined,
287
worktree: undefined,
288
worktreeProperties: undefined,
289
},
290
});
291
292
await handler.startRequest('session-1', request, false, makeWorkspace());
293
await handler.endRequest('session-1', request, session, makeToken());
294
295
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
296
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
297
// Checkpoint should still be created
298
expect(checkpointService.handleRequestCompleted).toHaveBeenCalled();
299
});
300
301
it('defers handling when multiple requests are in flight (steering)', async () => {
302
const req1 = makeRequest('req-1');
303
const req2 = makeRequest('req-2');
304
const session = makeSession();
305
306
await handler.startRequest('session-1', req1, false, makeWorkspace());
307
await handler.startRequest('session-1', req2, false, makeWorkspace());
308
309
// First request completes — should defer (2 pending)
310
await handler.endRequest('session-1', req1, session, makeToken());
311
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
312
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
313
expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();
314
315
// Second (last) request completes — should proceed
316
await handler.endRequest('session-1', req2, session, makeToken());
317
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
318
expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-2');
319
});
320
321
it('skips everything when token is cancelled', async () => {
322
const request = makeRequest();
323
const session = makeSession();
324
325
await handler.startRequest('session-1', request, false, makeWorkspace());
326
await handler.endRequest('session-1', request, session, makeToken(true));
327
328
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
329
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
330
expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();
331
});
332
333
it('calls PR detection service on completion', async () => {
334
const request = makeRequest();
335
const session = makeSession();
336
337
await handler.startRequest('session-1', request, false, makeWorkspace());
338
await handler.endRequest('session-1', request, session, makeToken());
339
340
// PR detection is fire-and-forget; wait for microtask
341
await new Promise(resolve => setTimeout(resolve, 10));
342
expect(prDetectionService.handlePullRequestCreated).toHaveBeenCalledWith('session-1', undefined);
343
});
344
345
it('cleans up tracked request even when commit throws', async () => {
346
workspaceFolderService.handleRequestCompleted.mockRejectedValue(new Error('commit failed'));
347
const request = makeRequest();
348
const session = makeSession();
349
350
await handler.startRequest('session-1', request, false, makeWorkspace());
351
await expect(handler.endRequest('session-1', request, session, makeToken())).rejects.toThrow('commit failed');
352
353
// After the error, a new request for the same session should proceed normally
354
workspaceFolderService.handleRequestCompleted.mockResolvedValue();
355
const req2 = makeRequest('req-2');
356
await handler.startRequest('session-1', req2, false, makeWorkspace());
357
await handler.endRequest('session-1', req2, session, makeToken());
358
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledTimes(2);
359
});
360
361
it('handles request without prior tracking gracefully', async () => {
362
const request = makeRequest();
363
const session = makeSession();
364
365
// Not tracked, but should still work (pendingRequests is undefined → size check skipped)
366
await handler.endRequest('session-1', request, session, makeToken());
367
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalled();
368
});
369
});
370
});
371
372