Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.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 { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
7
import type Anthropic from '@anthropic-ai/sdk';
8
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
import type * as vscode from 'vscode';
10
import { CancellationToken, CancellationTokenSource } from '../../../../../util/vs/base/common/cancellation';
11
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
12
import { URI } from '../../../../../util/vs/base/common/uri';
13
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
14
import { ChatReferenceBinaryData } from '../../../../../vscodeTypes';
15
import { LanguageModelToolMCPSource } from '../../../../../util/common/test/shims/chatTypes';
16
import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService';
17
import type { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
18
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
19
import { MockChatResponseStream, TestChatRequest } from '../../../../test/node/testHelpers';
20
import type { ClaudeFolderInfo } from '../../common/claudeFolderInfo';
21
import { ClaudeAgentManager, ClaudeCodeSession } from '../claudeCodeAgent';
22
import { IClaudeCodeSdkService } from '../claudeCodeSdkService';
23
import { ClaudeLanguageModelServer } from '../claudeLanguageModelServer';
24
import { parseClaudeModelId } from '../claudeModelId';
25
import type { ParsedClaudeModelId } from '../../common/claudeModelId';
26
import { IClaudeSessionStateService } from '../../common/claudeSessionStateService';
27
import { MockClaudeCodeSdkService } from './mockClaudeCodeSdkService';
28
29
function createMockLangModelServer(): ClaudeLanguageModelServer {
30
return {
31
incrementUserInitiatedMessageCount: vi.fn(),
32
getConfig: () => ({ port: 8080, nonce: 'test-nonce' }),
33
} as unknown as ClaudeLanguageModelServer;
34
}
35
36
function createMockChatRequest(prompt = ''): vscode.ChatRequest {
37
return { prompt, references: [], tools: new Map(), id: 'test-request-id', toolInvocationToken: {} } as unknown as vscode.ChatRequest;
38
}
39
40
const TEST_MODEL_ID = parseClaudeModelId('claude-3-sonnet');
41
const TEST_MODEL_ID_ALT = parseClaudeModelId('claude-3-opus');
42
const TEST_PERMISSION_MODE = 'acceptEdits' as const;
43
const TEST_FOLDER_INFO: ClaudeFolderInfo = { cwd: '/test/project', additionalDirectories: [] };
44
const TEST_SESSION_ID = 'test-session-id';
45
46
/**
47
* Commits test state to the session state service for a given session ID.
48
* This is required before calling handleRequest() since the agent manager
49
* now reads state from the service instead of accepting it as parameters.
50
*/
51
function commitTestState(
52
sessionStateService: IClaudeSessionStateService,
53
sessionId: string,
54
modelId: ParsedClaudeModelId | undefined = TEST_MODEL_ID,
55
permissionMode: PermissionMode = TEST_PERMISSION_MODE,
56
folderInfo: ClaudeFolderInfo = TEST_FOLDER_INFO,
57
): void {
58
sessionStateService.setModelIdForSession(sessionId, modelId);
59
sessionStateService.setPermissionModeForSession(sessionId, permissionMode);
60
sessionStateService.setFolderInfoForSession(sessionId, folderInfo);
61
}
62
63
describe('ClaudeAgentManager', () => {
64
const store = new DisposableStore();
65
let instantiationService: IInstantiationService;
66
let mockService: MockClaudeCodeSdkService;
67
let sessionStateService: IClaudeSessionStateService;
68
69
beforeEach(() => {
70
const services = store.add(createExtensionUnitTestingServices());
71
const accessor = services.createTestingAccessor();
72
instantiationService = accessor.get(IInstantiationService);
73
74
// Reset mock service call count
75
mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;
76
mockService.queryCallCount = 0;
77
78
sessionStateService = accessor.get(IClaudeSessionStateService);
79
});
80
81
afterEach(() => {
82
store.clear();
83
vi.resetAllMocks();
84
});
85
86
it('reuses a live session across requests and streams assistant text', async () => {
87
const manager = instantiationService.createInstance(ClaudeAgentManager);
88
89
// Use MockChatResponseStream to capture markdown output
90
const stream1 = new MockChatResponseStream();
91
92
commitTestState(sessionStateService, TEST_SESSION_ID);
93
const req1 = new TestChatRequest('Hi');
94
await manager.handleRequest(TEST_SESSION_ID, req1, stream1, CancellationToken.None, true);
95
96
expect(stream1.output.join('\n')).toContain('Hello from mock!');
97
98
// Second request should reuse the same live session (SDK query created only once)
99
const stream2 = new MockChatResponseStream();
100
101
const req2 = new TestChatRequest('Again');
102
await manager.handleRequest(TEST_SESSION_ID, req2, stream2, CancellationToken.None, false);
103
104
expect(stream2.output.join('\n')).toContain('Hello from mock!');
105
106
// Verify session continuity: the service's query method was called only once (proving session reuse)
107
expect(mockService.queryCallCount).toBe(1);
108
});
109
110
it('resolves image references as ImageBlockParam content blocks', async () => {
111
const manager = instantiationService.createInstance(ClaudeAgentManager);
112
const stream = new MockChatResponseStream();
113
114
const imageData = new Uint8Array([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes
115
const imageRef: vscode.ChatPromptReference = {
116
id: 'image-1',
117
name: 'image-1',
118
value: new ChatReferenceBinaryData('image/png', () => Promise.resolve(imageData)),
119
};
120
commitTestState(sessionStateService, TEST_SESSION_ID);
121
const req = new TestChatRequest('What is in this image?', [imageRef]);
122
await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);
123
124
expect(mockService.receivedMessages).toHaveLength(1);
125
const content = mockService.receivedMessages[0].message.content;
126
expect(Array.isArray(content)).toBe(true);
127
128
const blocks = content as Anthropic.ContentBlockParam[];
129
const imageBlocks = blocks.filter(b => b.type === 'image');
130
expect(imageBlocks).toHaveLength(1);
131
132
const imageBlock = imageBlocks[0] as Anthropic.ImageBlockParam;
133
expect(imageBlock.source.type).toBe('base64');
134
const source = imageBlock.source as Anthropic.Base64ImageSource;
135
expect(source.media_type).toBe('image/png');
136
expect(source.data).toBe(Buffer.from(imageData).toString('base64'));
137
138
// The text prompt should still be present
139
const textBlocks = blocks.filter(b => b.type === 'text') as Anthropic.TextBlockParam[];
140
expect(textBlocks.some(b => b.text === 'What is in this image?')).toBe(true);
141
});
142
143
it('normalizes image/jpg to image/jpeg', async () => {
144
const manager = instantiationService.createInstance(ClaudeAgentManager);
145
const stream = new MockChatResponseStream();
146
147
const imageRef: vscode.ChatPromptReference = {
148
id: 'image-1',
149
name: 'image-1',
150
value: new ChatReferenceBinaryData('image/jpg', () => Promise.resolve(new Uint8Array([0xFF, 0xD8]))),
151
};
152
commitTestState(sessionStateService, TEST_SESSION_ID);
153
const req = new TestChatRequest('Describe this', [imageRef]);
154
await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);
155
156
const blocks = mockService.receivedMessages[0].message.content as Anthropic.ContentBlockParam[];
157
const imageBlock = blocks.find(b => b.type === 'image') as Anthropic.ImageBlockParam;
158
expect(imageBlock).toBeDefined();
159
expect((imageBlock.source as Anthropic.Base64ImageSource).media_type).toBe('image/jpeg');
160
});
161
162
it('skips unsupported image MIME types', async () => {
163
const manager = instantiationService.createInstance(ClaudeAgentManager);
164
const stream = new MockChatResponseStream();
165
166
const imageRef: vscode.ChatPromptReference = {
167
id: 'image-1',
168
name: 'image-1',
169
value: new ChatReferenceBinaryData('image/bmp', () => Promise.resolve(new Uint8Array([0x42, 0x4D]))),
170
};
171
commitTestState(sessionStateService, TEST_SESSION_ID);
172
const req = new TestChatRequest('Describe this', [imageRef]);
173
await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);
174
175
const blocks = mockService.receivedMessages[0].message.content as Anthropic.ContentBlockParam[];
176
const imageBlocks = blocks.filter(b => b.type === 'image');
177
expect(imageBlocks).toHaveLength(0);
178
});
179
180
it('handles mixed image and file references', async () => {
181
const manager = instantiationService.createInstance(ClaudeAgentManager);
182
const stream = new MockChatResponseStream();
183
184
const imageRef: vscode.ChatPromptReference = {
185
id: 'image-1',
186
name: 'image-1',
187
value: new ChatReferenceBinaryData('image/png', () => Promise.resolve(new Uint8Array([0x89]))),
188
};
189
const fileUri = URI.file('/test/file.ts');
190
const fileRef: vscode.ChatPromptReference = {
191
id: 'file-1',
192
name: 'file-1',
193
value: fileUri,
194
};
195
commitTestState(sessionStateService, TEST_SESSION_ID);
196
const req = new TestChatRequest('Explain both', [imageRef, fileRef]);
197
await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);
198
199
const blocks = mockService.receivedMessages[0].message.content as Anthropic.ContentBlockParam[];
200
const imageBlocks = blocks.filter(b => b.type === 'image');
201
const textBlocks = blocks.filter(b => b.type === 'text') as Anthropic.TextBlockParam[];
202
expect(imageBlocks).toHaveLength(1);
203
// File reference should appear in system-reminder text block (use fsPath for cross-platform)
204
expect(textBlocks.some(b => b.text.includes(fileUri.fsPath))).toBe(true);
205
// User prompt should still be present
206
expect(textBlocks.some(b => b.text === 'Explain both')).toBe(true);
207
});
208
});
209
210
describe('ClaudeCodeSession', () => {
211
const store = new DisposableStore();
212
let instantiationService: IInstantiationService;
213
let sessionStateService: IClaudeSessionStateService;
214
215
beforeEach(() => {
216
const services = store.add(createExtensionUnitTestingServices());
217
const accessor = services.createTestingAccessor();
218
instantiationService = accessor.get(IInstantiationService);
219
sessionStateService = accessor.get(IClaudeSessionStateService);
220
});
221
222
afterEach(() => {
223
store.clear();
224
vi.resetAllMocks();
225
});
226
227
it('processes a single request correctly', async () => {
228
const mockServer = createMockLangModelServer();
229
commitTestState(sessionStateService, 'test-session');
230
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
231
const stream = new MockChatResponseStream();
232
233
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
234
235
expect(stream.output.join('\n')).toContain('Hello from mock!');
236
});
237
238
it('queues multiple requests and processes them sequentially', async () => {
239
const mockServer = createMockLangModelServer();
240
commitTestState(sessionStateService, 'test-session');
241
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
242
243
const stream1 = new MockChatResponseStream();
244
const stream2 = new MockChatResponseStream();
245
246
// Start both requests simultaneously
247
const promise1 = session.invoke(createMockChatRequest('First'), stream1, undefined, CancellationToken.None);
248
const promise2 = session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None);
249
250
// Wait for both to complete
251
await Promise.all([promise1, promise2]);
252
253
// Both should have received responses
254
expect(stream1.output.join('\n')).toContain('Hello from mock!');
255
expect(stream2.output.join('\n')).toContain('Hello from mock!');
256
});
257
258
it('cancels pending requests when cancelled', async () => {
259
const mockServer = createMockLangModelServer();
260
commitTestState(sessionStateService, 'test-session');
261
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
262
const stream = new MockChatResponseStream();
263
const source = new CancellationTokenSource();
264
source.cancel();
265
266
await expect(session.invoke(createMockChatRequest('Hello'), stream, undefined, source.token)).rejects.toThrow();
267
});
268
269
it('cleans up resources when disposed', async () => {
270
const mockServer = createMockLangModelServer();
271
commitTestState(sessionStateService, 'test-session');
272
const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true);
273
274
// Dispose the session immediately
275
session.dispose();
276
277
// Any new requests should be rejected
278
const stream = new MockChatResponseStream();
279
await expect(session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None))
280
.rejects.toThrow('Session disposed');
281
});
282
283
it('handles multiple sessions with different session IDs', async () => {
284
const mockServer1 = createMockLangModelServer();
285
const mockServer2 = createMockLangModelServer();
286
commitTestState(sessionStateService, 'session-1');
287
commitTestState(sessionStateService, 'session-2');
288
const session1 = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer1, 'session-1', true));
289
const session2 = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer2, 'session-2', true));
290
291
expect(session1.sessionId).toBe('session-1');
292
expect(session2.sessionId).toBe('session-2');
293
294
const stream1 = new MockChatResponseStream();
295
const stream2 = new MockChatResponseStream();
296
297
// Both sessions should work independently
298
await Promise.all([
299
session1.invoke(createMockChatRequest('Hello from session 1'), stream1, undefined, CancellationToken.None),
300
session2.invoke(createMockChatRequest('Hello from session 2'), stream2, undefined, CancellationToken.None)
301
]);
302
303
expect(stream1.output.join('\n')).toContain('Hello from mock!');
304
expect(stream2.output.join('\n')).toContain('Hello from mock!');
305
});
306
307
it('initializes with model ID from constructor', async () => {
308
const mockServer = createMockLangModelServer();
309
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID_ALT);
310
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
311
const stream = new MockChatResponseStream();
312
313
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
314
315
expect(stream.output.join('\n')).toContain('Hello from mock!');
316
});
317
318
it('calls setModel when model changes instead of restarting session', async () => {
319
const mockServer = createMockLangModelServer();
320
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
321
mockService.queryCallCount = 0;
322
mockService.setModelCallCount = 0;
323
324
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
325
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
326
327
// First request with initial model
328
const stream1 = new MockChatResponseStream();
329
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
330
expect(mockService.queryCallCount).toBe(1);
331
332
// Update model in session state service for the second request
333
sessionStateService.setModelIdForSession('test-session', TEST_MODEL_ID_ALT);
334
335
// Second request with different model should call setModel on existing session
336
const stream2 = new MockChatResponseStream();
337
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
338
expect(mockService.queryCallCount).toBe(1); // Same query reused
339
expect(mockService.setModelCallCount).toBe(1); // setModel was called
340
expect(mockService.lastSetModel).toBe(TEST_MODEL_ID_ALT.toSdkModelId());
341
});
342
343
it('does not restart session when same model is used', async () => {
344
const mockServer = createMockLangModelServer();
345
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
346
mockService.queryCallCount = 0;
347
348
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
349
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
350
351
// First request
352
const stream1 = new MockChatResponseStream();
353
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
354
expect(mockService.queryCallCount).toBe(1);
355
356
// Second request with same model should reuse session
357
const stream2 = new MockChatResponseStream();
358
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
359
expect(mockService.queryCallCount).toBe(1); // Same query reused
360
});
361
362
it('uses session state model for initial Options when starting a new session', async () => {
363
const mockServer = createMockLangModelServer();
364
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
365
366
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID_ALT);
367
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
368
const stream = new MockChatResponseStream();
369
370
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
371
372
// The Options passed to the SDK should reflect the session state model
373
expect(mockService.lastQueryOptions?.model).toBe(TEST_MODEL_ID_ALT.toSdkModelId());
374
});
375
376
it('uses session state permission mode for initial Options when starting a new session', async () => {
377
const mockServer = createMockLangModelServer();
378
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
379
380
// Session state overrides the default permission mode
381
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID, 'bypassPermissions');
382
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
383
const stream = new MockChatResponseStream();
384
385
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
386
387
// The Options passed to the SDK should reflect the session state permission mode
388
expect(mockService.lastQueryOptions?.permissionMode).toBe('bypassPermissions');
389
});
390
391
it('does not call setModel when model has not changed', async () => {
392
const mockServer = createMockLangModelServer();
393
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
394
mockService.setModelCallCount = 0;
395
396
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
397
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
398
399
// First request establishes the session
400
const stream1 = new MockChatResponseStream();
401
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
402
403
// Second request with same model should not call setModel
404
const stream2 = new MockChatResponseStream();
405
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
406
407
expect(mockService.setModelCallCount).toBe(0);
408
});
409
410
it('does not call setPermissionMode when permission mode has not changed', async () => {
411
const mockServer = createMockLangModelServer();
412
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
413
mockService.setPermissionModeCallCount = 0;
414
415
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID, 'acceptEdits');
416
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
417
418
// First request establishes the session
419
const stream1 = new MockChatResponseStream();
420
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
421
422
// Second request with same permission mode should not call setPermissionMode
423
const stream2 = new MockChatResponseStream();
424
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
425
426
expect(mockService.setPermissionModeCallCount).toBe(0);
427
});
428
429
it('calls setPermissionMode when permission mode changes', async () => {
430
const mockServer = createMockLangModelServer();
431
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
432
mockService.setPermissionModeCallCount = 0;
433
434
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID, 'acceptEdits');
435
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
436
437
// First request establishes the session
438
const stream1 = new MockChatResponseStream();
439
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
440
441
// Change permission mode in session state for the second request
442
sessionStateService.setPermissionModeForSession('test-session', 'bypassPermissions');
443
444
// Second request should call setPermissionMode
445
const stream2 = new MockChatResponseStream();
446
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
447
448
expect(mockService.setPermissionModeCallCount).toBe(1);
449
expect(mockService.lastSetPermissionMode).toBe('bypassPermissions');
450
});
451
452
it('passes sessionId in SDK options for new sessions', async () => {
453
const mockServer = createMockLangModelServer();
454
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
455
456
commitTestState(sessionStateService, 'new-session');
457
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'new-session', true));
458
const stream = new MockChatResponseStream();
459
460
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
461
462
// New session should use sessionId, not resume
463
expect(mockService.lastQueryOptions?.sessionId).toBe('new-session');
464
expect(mockService.lastQueryOptions?.resume).toBeUndefined();
465
});
466
467
it('passes resume in SDK options for resumed sessions', async () => {
468
const mockServer = createMockLangModelServer();
469
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
470
471
commitTestState(sessionStateService, 'existing-session');
472
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'existing-session', false));
473
const stream = new MockChatResponseStream();
474
475
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
476
477
// Resumed session should use resume, not sessionId
478
expect(mockService.lastQueryOptions?.resume).toBe('existing-session');
479
expect(mockService.lastQueryOptions?.sessionId).toBeUndefined();
480
});
481
482
it('passes effort in SDK options when reasoning effort is set in session state', async () => {
483
const mockServer = createMockLangModelServer();
484
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
485
486
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
487
sessionStateService.setReasoningEffortForSession('test-session', 'low');
488
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
489
const stream = new MockChatResponseStream();
490
491
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
492
493
expect(mockService.lastQueryOptions?.effort).toBe('low');
494
});
495
496
it('does not include effort in SDK options when reasoning effort is not set', async () => {
497
const mockServer = createMockLangModelServer();
498
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
499
500
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
501
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
502
const stream = new MockChatResponseStream();
503
504
await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
505
506
expect(mockService.lastQueryOptions?.effort).toBeUndefined();
507
});
508
509
it('restarts session when effort level changes', async () => {
510
const mockServer = createMockLangModelServer();
511
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
512
mockService.queryCallCount = 0;
513
514
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
515
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
516
517
// First request with no effort
518
const stream1 = new MockChatResponseStream();
519
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
520
expect(mockService.queryCallCount).toBe(1);
521
522
// Change effort level
523
sessionStateService.setReasoningEffortForSession('test-session', 'high');
524
525
// Second request should restart session (new query created)
526
const stream2 = new MockChatResponseStream();
527
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
528
expect(mockService.queryCallCount).toBe(2);
529
});
530
531
it('does not restart session when effort level is unchanged', async () => {
532
const mockServer = createMockLangModelServer();
533
const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;
534
mockService.queryCallCount = 0;
535
536
commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);
537
sessionStateService.setReasoningEffortForSession('test-session', 'medium');
538
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
539
540
// First request
541
const stream1 = new MockChatResponseStream();
542
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
543
expect(mockService.queryCallCount).toBe(1);
544
545
// Second request with same effort level
546
const stream2 = new MockChatResponseStream();
547
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
548
expect(mockService.queryCallCount).toBe(1);
549
});
550
});
551
552
describe('ClaudeAgentManager - error handling', () => {
553
const store = new DisposableStore();
554
let instantiationService: IInstantiationService;
555
556
beforeEach(() => {
557
const services = store.add(createExtensionUnitTestingServices());
558
const accessor = services.createTestingAccessor();
559
instantiationService = accessor.get(IInstantiationService);
560
});
561
562
afterEach(() => {
563
store.clear();
564
vi.resetAllMocks();
565
});
566
567
it('throws when session state has not been committed', async () => {
568
const manager = instantiationService.createInstance(ClaudeAgentManager);
569
const stream = new MockChatResponseStream();
570
571
// Do NOT commit state - handleRequest should fail
572
const req = new TestChatRequest('Hello');
573
const result = await manager.handleRequest('no-state-session', req, stream, CancellationToken.None, true);
574
575
// Should return an error result (the error is caught and streamed)
576
expect(result.errorDetails).toBeDefined();
577
});
578
});
579
580
describe('ClaudeCodeSession - yield flow', () => {
581
const store = new DisposableStore();
582
let instantiationService: IInstantiationService;
583
let sessionStateService: IClaudeSessionStateService;
584
let mockService: MockClaudeCodeSdkService;
585
586
beforeEach(() => {
587
const services = store.add(createExtensionUnitTestingServices());
588
const accessor = services.createTestingAccessor();
589
instantiationService = accessor.get(IInstantiationService);
590
sessionStateService = accessor.get(IClaudeSessionStateService);
591
mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;
592
mockService.queryCallCount = 0;
593
});
594
595
afterEach(() => {
596
store.clear();
597
vi.resetAllMocks();
598
});
599
600
it('yield completes the current request while session continues', async () => {
601
const mockServer = createMockLangModelServer();
602
commitTestState(sessionStateService, 'test-session');
603
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
604
605
const stream1 = new MockChatResponseStream();
606
// yieldRequested is set before _processMessages runs (async session start),
607
// so the yield check triggers on the first dispatched message
608
const promise1 = session.invoke(createMockChatRequest('First'), stream1, () => true, CancellationToken.None);
609
await promise1;
610
611
// Session should still be alive — send a second request
612
const stream2 = new MockChatResponseStream();
613
const promise2 = session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None);
614
await promise2;
615
616
expect(stream2.output.join('\n')).toContain('Hello from mock!');
617
expect(mockService.queryCallCount).toBe(1);
618
});
619
620
it('second request after yield uses priority now', async () => {
621
const mockServer = createMockLangModelServer();
622
commitTestState(sessionStateService, 'test-session');
623
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
624
625
const stream1 = new MockChatResponseStream();
626
await session.invoke(createMockChatRequest('First'), stream1, () => true, CancellationToken.None);
627
628
const stream2 = new MockChatResponseStream();
629
await session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None);
630
631
// The second message yielded to the SDK should have priority 'now'
632
expect(mockService.receivedMessages.length).toBeGreaterThanOrEqual(2);
633
expect(mockService.receivedMessages[1].priority).toBe('now');
634
});
635
636
it('multiple yield cycles work correctly', async () => {
637
const mockServer = createMockLangModelServer();
638
commitTestState(sessionStateService, 'test-session');
639
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
640
641
// A → yield → B → yield → C
642
const streamA = new MockChatResponseStream();
643
await session.invoke(createMockChatRequest('A'), streamA, () => true, CancellationToken.None);
644
645
const streamB = new MockChatResponseStream();
646
await session.invoke(createMockChatRequest('B'), streamB, () => true, CancellationToken.None);
647
648
const streamC = new MockChatResponseStream();
649
await session.invoke(createMockChatRequest('C'), streamC, undefined, CancellationToken.None);
650
651
expect(streamC.output.join('\n')).toContain('Hello from mock!');
652
expect(mockService.queryCallCount).toBe(1);
653
expect(mockService.receivedMessages).toHaveLength(3);
654
});
655
});
656
657
describe('ClaudeCodeSession - settings change restart', () => {
658
const store = new DisposableStore();
659
let instantiationService: IInstantiationService;
660
let sessionStateService: IClaudeSessionStateService;
661
let mockService: MockClaudeCodeSdkService;
662
let mockFs: MockFileSystemService;
663
664
beforeEach(() => {
665
const services = store.add(createExtensionUnitTestingServices());
666
const accessor = services.createTestingAccessor();
667
instantiationService = accessor.get(IInstantiationService);
668
sessionStateService = accessor.get(IClaudeSessionStateService);
669
mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;
670
mockService.queryCallCount = 0;
671
mockFs = accessor.get(IFileSystemService) as MockFileSystemService;
672
});
673
674
afterEach(() => {
675
store.clear();
676
vi.resetAllMocks();
677
});
678
679
it('restarts session when settings files change between requests', async () => {
680
const mockServer = createMockLangModelServer();
681
commitTestState(sessionStateService, 'test-session');
682
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
683
684
// First request establishes the session and takes a settings snapshot
685
const stream1 = new MockChatResponseStream();
686
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
687
expect(mockService.queryCallCount).toBe(1);
688
689
// Simulate a CLAUDE.md file being created (settings change)
690
const claudeMdUri = URI.joinPath(URI.file('/home/testuser'), '.claude', 'CLAUDE.md');
691
mockFs.mockFile(claudeMdUri, '# Instructions', 2000);
692
693
// Second request should trigger settings change → restart (new query created)
694
const stream2 = new MockChatResponseStream();
695
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
696
expect(mockService.queryCallCount).toBe(2);
697
});
698
699
it('uses resume after settings change restart', async () => {
700
const mockServer = createMockLangModelServer();
701
commitTestState(sessionStateService, 'test-session');
702
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
703
704
// First request — new session
705
const stream1 = new MockChatResponseStream();
706
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
707
expect(mockService.lastQueryOptions?.sessionId).toBe('test-session');
708
709
// Trigger settings change
710
const claudeMdUri = URI.joinPath(URI.file('/home/testuser'), '.claude', 'CLAUDE.md');
711
mockFs.mockFile(claudeMdUri, '# Instructions', 2000);
712
713
// Second request — should use resume, not sessionId
714
const stream2 = new MockChatResponseStream();
715
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
716
expect(mockService.lastQueryOptions?.resume).toBe('test-session');
717
expect(mockService.lastQueryOptions?.sessionId).toBeUndefined();
718
});
719
720
it('does not restart when settings files have not changed', async () => {
721
const mockServer = createMockLangModelServer();
722
commitTestState(sessionStateService, 'test-session');
723
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
724
725
const stream1 = new MockChatResponseStream();
726
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
727
expect(mockService.queryCallCount).toBe(1);
728
729
// No file changes — session should be reused
730
const stream2 = new MockChatResponseStream();
731
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
732
expect(mockService.queryCallCount).toBe(1);
733
});
734
});
735
736
describe('ClaudeCodeSession - effort and tools restart', () => {
737
const store = new DisposableStore();
738
let instantiationService: IInstantiationService;
739
let sessionStateService: IClaudeSessionStateService;
740
let mockService: MockClaudeCodeSdkService;
741
742
beforeEach(() => {
743
const services = store.add(createExtensionUnitTestingServices());
744
const accessor = services.createTestingAccessor();
745
instantiationService = accessor.get(IInstantiationService);
746
sessionStateService = accessor.get(IClaudeSessionStateService);
747
mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;
748
mockService.queryCallCount = 0;
749
});
750
751
afterEach(() => {
752
store.clear();
753
vi.resetAllMocks();
754
});
755
756
it('uses resume after effort change restart', async () => {
757
const mockServer = createMockLangModelServer();
758
commitTestState(sessionStateService, 'test-session');
759
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
760
761
// First request — new session
762
const stream1 = new MockChatResponseStream();
763
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
764
expect(mockService.lastQueryOptions?.sessionId).toBe('test-session');
765
766
// Change effort
767
sessionStateService.setReasoningEffortForSession('test-session', 'high');
768
769
// Restarted session should use resume
770
const stream2 = new MockChatResponseStream();
771
await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);
772
expect(mockService.lastQueryOptions?.resume).toBe('test-session');
773
expect(mockService.lastQueryOptions?.effort).toBe('high');
774
});
775
776
it('restarts session when MCP tools change', async () => {
777
const mockServer = createMockLangModelServer();
778
commitTestState(sessionStateService, 'test-session');
779
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
780
781
// First request with no MCP tools
782
const stream1 = new MockChatResponseStream();
783
await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);
784
expect(mockService.queryCallCount).toBe(1);
785
786
// Second request with a new MCP tool
787
const stream2 = new MockChatResponseStream();
788
const mcpTool = { name: 'mcp-tool', source: new LanguageModelToolMCPSource('test-server', 'test-server', undefined) } as unknown as vscode.LanguageModelChatTool;
789
const reqWithTool: vscode.ChatRequest = {
790
prompt: 'Hello again',
791
references: [],
792
tools: new Map([[mcpTool, true]]),
793
id: 'test-request-2',
794
toolInvocationToken: {}
795
} as unknown as vscode.ChatRequest;
796
await session.invoke(reqWithTool, stream2, undefined, CancellationToken.None);
797
expect(mockService.queryCallCount).toBe(2);
798
});
799
800
it('does not restart when MCP tools are unchanged', async () => {
801
const mockServer = createMockLangModelServer();
802
commitTestState(sessionStateService, 'test-session');
803
const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));
804
805
const mcpTool = { name: 'mcp-tool', source: new LanguageModelToolMCPSource('test-server', 'test-server', undefined) } as unknown as vscode.LanguageModelChatTool;
806
const makeReq = () => ({
807
prompt: 'Hello',
808
references: [],
809
tools: new Map([[mcpTool, true]]),
810
id: 'test-request',
811
toolInvocationToken: {}
812
} as unknown as vscode.ChatRequest);
813
814
const stream1 = new MockChatResponseStream();
815
await session.invoke(makeReq(), stream1, undefined, CancellationToken.None);
816
expect(mockService.queryCallCount).toBe(1);
817
818
const stream2 = new MockChatResponseStream();
819
await session.invoke(makeReq(), stream2, undefined, CancellationToken.None);
820
expect(mockService.queryCallCount).toBe(1);
821
});
822
});
823
824
describe('ClaudeCodeSession - edge cases', () => {
825
const store = new DisposableStore();
826
let instantiationService: IInstantiationService;
827
let sessionStateService: IClaudeSessionStateService;
828
829
beforeEach(() => {
830
const services = store.add(createExtensionUnitTestingServices());
831
const accessor = services.createTestingAccessor();
832
instantiationService = accessor.get(IInstantiationService);
833
sessionStateService = accessor.get(IClaudeSessionStateService);
834
});
835
836
afterEach(() => {
837
store.clear();
838
vi.resetAllMocks();
839
});
840
841
it('rejects in-flight requests when disposed', async () => {
842
const mockServer = createMockLangModelServer();
843
commitTestState(sessionStateService, 'test-session');
844
const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true);
845
846
const stream = new MockChatResponseStream();
847
const promise = session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);
848
849
// Dispose immediately — the in-flight request should be rejected
850
session.dispose();
851
852
await expect(promise).rejects.toThrow();
853
});
854
855
it('rejects new requests after dispose', async () => {
856
const mockServer = createMockLangModelServer();
857
commitTestState(sessionStateService, 'test-session');
858
const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true);
859
session.dispose();
860
861
const stream = new MockChatResponseStream();
862
await expect(
863
session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None)
864
).rejects.toThrow('Session disposed');
865
});
866
});
867
868