Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.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 { PermissionMode } from '@anthropic-ai/claude-agent-sdk';6import type Anthropic from '@anthropic-ai/sdk';7import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';8import type * as vscode from 'vscode';9import { CancellationToken, CancellationTokenSource } from '../../../../../util/vs/base/common/cancellation';10import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';11import { URI } from '../../../../../util/vs/base/common/uri';12import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';13import { ChatReferenceBinaryData } from '../../../../../vscodeTypes';14import { LanguageModelToolMCPSource } from '../../../../../util/common/test/shims/chatTypes';15import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService';16import type { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';17import { createExtensionUnitTestingServices } from '../../../../test/node/services';18import { MockChatResponseStream, TestChatRequest } from '../../../../test/node/testHelpers';19import type { ClaudeFolderInfo } from '../../common/claudeFolderInfo';20import { ClaudeAgentManager, ClaudeCodeSession } from '../claudeCodeAgent';21import { IClaudeCodeSdkService } from '../claudeCodeSdkService';22import { ClaudeLanguageModelServer } from '../claudeLanguageModelServer';23import { parseClaudeModelId } from '../claudeModelId';24import type { ParsedClaudeModelId } from '../../common/claudeModelId';25import { IClaudeSessionStateService } from '../../common/claudeSessionStateService';26import { MockClaudeCodeSdkService } from './mockClaudeCodeSdkService';2728function createMockLangModelServer(): ClaudeLanguageModelServer {29return {30incrementUserInitiatedMessageCount: vi.fn(),31getConfig: () => ({ port: 8080, nonce: 'test-nonce' }),32} as unknown as ClaudeLanguageModelServer;33}3435function createMockChatRequest(prompt = ''): vscode.ChatRequest {36return { prompt, references: [], tools: new Map(), id: 'test-request-id', toolInvocationToken: {} } as unknown as vscode.ChatRequest;37}3839const TEST_MODEL_ID = parseClaudeModelId('claude-3-sonnet');40const TEST_MODEL_ID_ALT = parseClaudeModelId('claude-3-opus');41const TEST_PERMISSION_MODE = 'acceptEdits' as const;42const TEST_FOLDER_INFO: ClaudeFolderInfo = { cwd: '/test/project', additionalDirectories: [] };43const TEST_SESSION_ID = 'test-session-id';4445/**46* Commits test state to the session state service for a given session ID.47* This is required before calling handleRequest() since the agent manager48* now reads state from the service instead of accepting it as parameters.49*/50function commitTestState(51sessionStateService: IClaudeSessionStateService,52sessionId: string,53modelId: ParsedClaudeModelId | undefined = TEST_MODEL_ID,54permissionMode: PermissionMode = TEST_PERMISSION_MODE,55folderInfo: ClaudeFolderInfo = TEST_FOLDER_INFO,56): void {57sessionStateService.setModelIdForSession(sessionId, modelId);58sessionStateService.setPermissionModeForSession(sessionId, permissionMode);59sessionStateService.setFolderInfoForSession(sessionId, folderInfo);60}6162describe('ClaudeAgentManager', () => {63const store = new DisposableStore();64let instantiationService: IInstantiationService;65let mockService: MockClaudeCodeSdkService;66let sessionStateService: IClaudeSessionStateService;6768beforeEach(() => {69const services = store.add(createExtensionUnitTestingServices());70const accessor = services.createTestingAccessor();71instantiationService = accessor.get(IInstantiationService);7273// Reset mock service call count74mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;75mockService.queryCallCount = 0;7677sessionStateService = accessor.get(IClaudeSessionStateService);78});7980afterEach(() => {81store.clear();82vi.resetAllMocks();83});8485it('reuses a live session across requests and streams assistant text', async () => {86const manager = instantiationService.createInstance(ClaudeAgentManager);8788// Use MockChatResponseStream to capture markdown output89const stream1 = new MockChatResponseStream();9091commitTestState(sessionStateService, TEST_SESSION_ID);92const req1 = new TestChatRequest('Hi');93await manager.handleRequest(TEST_SESSION_ID, req1, stream1, CancellationToken.None, true);9495expect(stream1.output.join('\n')).toContain('Hello from mock!');9697// Second request should reuse the same live session (SDK query created only once)98const stream2 = new MockChatResponseStream();99100const req2 = new TestChatRequest('Again');101await manager.handleRequest(TEST_SESSION_ID, req2, stream2, CancellationToken.None, false);102103expect(stream2.output.join('\n')).toContain('Hello from mock!');104105// Verify session continuity: the service's query method was called only once (proving session reuse)106expect(mockService.queryCallCount).toBe(1);107});108109it('resolves image references as ImageBlockParam content blocks', async () => {110const manager = instantiationService.createInstance(ClaudeAgentManager);111const stream = new MockChatResponseStream();112113const imageData = new Uint8Array([0x89, 0x50, 0x4E, 0x47]); // PNG magic bytes114const imageRef: vscode.ChatPromptReference = {115id: 'image-1',116name: 'image-1',117value: new ChatReferenceBinaryData('image/png', () => Promise.resolve(imageData)),118};119commitTestState(sessionStateService, TEST_SESSION_ID);120const req = new TestChatRequest('What is in this image?', [imageRef]);121await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);122123expect(mockService.receivedMessages).toHaveLength(1);124const content = mockService.receivedMessages[0].message.content;125expect(Array.isArray(content)).toBe(true);126127const blocks = content as Anthropic.ContentBlockParam[];128const imageBlocks = blocks.filter(b => b.type === 'image');129expect(imageBlocks).toHaveLength(1);130131const imageBlock = imageBlocks[0] as Anthropic.ImageBlockParam;132expect(imageBlock.source.type).toBe('base64');133const source = imageBlock.source as Anthropic.Base64ImageSource;134expect(source.media_type).toBe('image/png');135expect(source.data).toBe(Buffer.from(imageData).toString('base64'));136137// The text prompt should still be present138const textBlocks = blocks.filter(b => b.type === 'text') as Anthropic.TextBlockParam[];139expect(textBlocks.some(b => b.text === 'What is in this image?')).toBe(true);140});141142it('normalizes image/jpg to image/jpeg', async () => {143const manager = instantiationService.createInstance(ClaudeAgentManager);144const stream = new MockChatResponseStream();145146const imageRef: vscode.ChatPromptReference = {147id: 'image-1',148name: 'image-1',149value: new ChatReferenceBinaryData('image/jpg', () => Promise.resolve(new Uint8Array([0xFF, 0xD8]))),150};151commitTestState(sessionStateService, TEST_SESSION_ID);152const req = new TestChatRequest('Describe this', [imageRef]);153await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);154155const blocks = mockService.receivedMessages[0].message.content as Anthropic.ContentBlockParam[];156const imageBlock = blocks.find(b => b.type === 'image') as Anthropic.ImageBlockParam;157expect(imageBlock).toBeDefined();158expect((imageBlock.source as Anthropic.Base64ImageSource).media_type).toBe('image/jpeg');159});160161it('skips unsupported image MIME types', async () => {162const manager = instantiationService.createInstance(ClaudeAgentManager);163const stream = new MockChatResponseStream();164165const imageRef: vscode.ChatPromptReference = {166id: 'image-1',167name: 'image-1',168value: new ChatReferenceBinaryData('image/bmp', () => Promise.resolve(new Uint8Array([0x42, 0x4D]))),169};170commitTestState(sessionStateService, TEST_SESSION_ID);171const req = new TestChatRequest('Describe this', [imageRef]);172await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);173174const blocks = mockService.receivedMessages[0].message.content as Anthropic.ContentBlockParam[];175const imageBlocks = blocks.filter(b => b.type === 'image');176expect(imageBlocks).toHaveLength(0);177});178179it('handles mixed image and file references', async () => {180const manager = instantiationService.createInstance(ClaudeAgentManager);181const stream = new MockChatResponseStream();182183const imageRef: vscode.ChatPromptReference = {184id: 'image-1',185name: 'image-1',186value: new ChatReferenceBinaryData('image/png', () => Promise.resolve(new Uint8Array([0x89]))),187};188const fileUri = URI.file('/test/file.ts');189const fileRef: vscode.ChatPromptReference = {190id: 'file-1',191name: 'file-1',192value: fileUri,193};194commitTestState(sessionStateService, TEST_SESSION_ID);195const req = new TestChatRequest('Explain both', [imageRef, fileRef]);196await manager.handleRequest(TEST_SESSION_ID, req, stream, CancellationToken.None, true);197198const blocks = mockService.receivedMessages[0].message.content as Anthropic.ContentBlockParam[];199const imageBlocks = blocks.filter(b => b.type === 'image');200const textBlocks = blocks.filter(b => b.type === 'text') as Anthropic.TextBlockParam[];201expect(imageBlocks).toHaveLength(1);202// File reference should appear in system-reminder text block (use fsPath for cross-platform)203expect(textBlocks.some(b => b.text.includes(fileUri.fsPath))).toBe(true);204// User prompt should still be present205expect(textBlocks.some(b => b.text === 'Explain both')).toBe(true);206});207});208209describe('ClaudeCodeSession', () => {210const store = new DisposableStore();211let instantiationService: IInstantiationService;212let sessionStateService: IClaudeSessionStateService;213214beforeEach(() => {215const services = store.add(createExtensionUnitTestingServices());216const accessor = services.createTestingAccessor();217instantiationService = accessor.get(IInstantiationService);218sessionStateService = accessor.get(IClaudeSessionStateService);219});220221afterEach(() => {222store.clear();223vi.resetAllMocks();224});225226it('processes a single request correctly', async () => {227const mockServer = createMockLangModelServer();228commitTestState(sessionStateService, 'test-session');229const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));230const stream = new MockChatResponseStream();231232await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);233234expect(stream.output.join('\n')).toContain('Hello from mock!');235});236237it('queues multiple requests and processes them sequentially', async () => {238const mockServer = createMockLangModelServer();239commitTestState(sessionStateService, 'test-session');240const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));241242const stream1 = new MockChatResponseStream();243const stream2 = new MockChatResponseStream();244245// Start both requests simultaneously246const promise1 = session.invoke(createMockChatRequest('First'), stream1, undefined, CancellationToken.None);247const promise2 = session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None);248249// Wait for both to complete250await Promise.all([promise1, promise2]);251252// Both should have received responses253expect(stream1.output.join('\n')).toContain('Hello from mock!');254expect(stream2.output.join('\n')).toContain('Hello from mock!');255});256257it('cancels pending requests when cancelled', async () => {258const mockServer = createMockLangModelServer();259commitTestState(sessionStateService, 'test-session');260const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));261const stream = new MockChatResponseStream();262const source = new CancellationTokenSource();263source.cancel();264265await expect(session.invoke(createMockChatRequest('Hello'), stream, undefined, source.token)).rejects.toThrow();266});267268it('cleans up resources when disposed', async () => {269const mockServer = createMockLangModelServer();270commitTestState(sessionStateService, 'test-session');271const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true);272273// Dispose the session immediately274session.dispose();275276// Any new requests should be rejected277const stream = new MockChatResponseStream();278await expect(session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None))279.rejects.toThrow('Session disposed');280});281282it('handles multiple sessions with different session IDs', async () => {283const mockServer1 = createMockLangModelServer();284const mockServer2 = createMockLangModelServer();285commitTestState(sessionStateService, 'session-1');286commitTestState(sessionStateService, 'session-2');287const session1 = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer1, 'session-1', true));288const session2 = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer2, 'session-2', true));289290expect(session1.sessionId).toBe('session-1');291expect(session2.sessionId).toBe('session-2');292293const stream1 = new MockChatResponseStream();294const stream2 = new MockChatResponseStream();295296// Both sessions should work independently297await Promise.all([298session1.invoke(createMockChatRequest('Hello from session 1'), stream1, undefined, CancellationToken.None),299session2.invoke(createMockChatRequest('Hello from session 2'), stream2, undefined, CancellationToken.None)300]);301302expect(stream1.output.join('\n')).toContain('Hello from mock!');303expect(stream2.output.join('\n')).toContain('Hello from mock!');304});305306it('initializes with model ID from constructor', async () => {307const mockServer = createMockLangModelServer();308commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID_ALT);309const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));310const stream = new MockChatResponseStream();311312await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);313314expect(stream.output.join('\n')).toContain('Hello from mock!');315});316317it('calls setModel when model changes instead of restarting session', async () => {318const mockServer = createMockLangModelServer();319const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;320mockService.queryCallCount = 0;321mockService.setModelCallCount = 0;322323commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);324const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));325326// First request with initial model327const stream1 = new MockChatResponseStream();328await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);329expect(mockService.queryCallCount).toBe(1);330331// Update model in session state service for the second request332sessionStateService.setModelIdForSession('test-session', TEST_MODEL_ID_ALT);333334// Second request with different model should call setModel on existing session335const stream2 = new MockChatResponseStream();336await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);337expect(mockService.queryCallCount).toBe(1); // Same query reused338expect(mockService.setModelCallCount).toBe(1); // setModel was called339expect(mockService.lastSetModel).toBe(TEST_MODEL_ID_ALT.toSdkModelId());340});341342it('does not restart session when same model is used', async () => {343const mockServer = createMockLangModelServer();344const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;345mockService.queryCallCount = 0;346347commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);348const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));349350// First request351const stream1 = new MockChatResponseStream();352await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);353expect(mockService.queryCallCount).toBe(1);354355// Second request with same model should reuse session356const stream2 = new MockChatResponseStream();357await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);358expect(mockService.queryCallCount).toBe(1); // Same query reused359});360361it('uses session state model for initial Options when starting a new session', async () => {362const mockServer = createMockLangModelServer();363const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;364365commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID_ALT);366const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));367const stream = new MockChatResponseStream();368369await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);370371// The Options passed to the SDK should reflect the session state model372expect(mockService.lastQueryOptions?.model).toBe(TEST_MODEL_ID_ALT.toSdkModelId());373});374375it('uses session state permission mode for initial Options when starting a new session', async () => {376const mockServer = createMockLangModelServer();377const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;378379// Session state overrides the default permission mode380commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID, 'bypassPermissions');381const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));382const stream = new MockChatResponseStream();383384await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);385386// The Options passed to the SDK should reflect the session state permission mode387expect(mockService.lastQueryOptions?.permissionMode).toBe('bypassPermissions');388});389390it('does not call setModel when model has not changed', async () => {391const mockServer = createMockLangModelServer();392const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;393mockService.setModelCallCount = 0;394395commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);396const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));397398// First request establishes the session399const stream1 = new MockChatResponseStream();400await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);401402// Second request with same model should not call setModel403const stream2 = new MockChatResponseStream();404await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);405406expect(mockService.setModelCallCount).toBe(0);407});408409it('does not call setPermissionMode when permission mode has not changed', async () => {410const mockServer = createMockLangModelServer();411const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;412mockService.setPermissionModeCallCount = 0;413414commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID, 'acceptEdits');415const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));416417// First request establishes the session418const stream1 = new MockChatResponseStream();419await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);420421// Second request with same permission mode should not call setPermissionMode422const stream2 = new MockChatResponseStream();423await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);424425expect(mockService.setPermissionModeCallCount).toBe(0);426});427428it('calls setPermissionMode when permission mode changes', async () => {429const mockServer = createMockLangModelServer();430const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;431mockService.setPermissionModeCallCount = 0;432433commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID, 'acceptEdits');434const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));435436// First request establishes the session437const stream1 = new MockChatResponseStream();438await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);439440// Change permission mode in session state for the second request441sessionStateService.setPermissionModeForSession('test-session', 'bypassPermissions');442443// Second request should call setPermissionMode444const stream2 = new MockChatResponseStream();445await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);446447expect(mockService.setPermissionModeCallCount).toBe(1);448expect(mockService.lastSetPermissionMode).toBe('bypassPermissions');449});450451it('passes sessionId in SDK options for new sessions', async () => {452const mockServer = createMockLangModelServer();453const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;454455commitTestState(sessionStateService, 'new-session');456const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'new-session', true));457const stream = new MockChatResponseStream();458459await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);460461// New session should use sessionId, not resume462expect(mockService.lastQueryOptions?.sessionId).toBe('new-session');463expect(mockService.lastQueryOptions?.resume).toBeUndefined();464});465466it('passes resume in SDK options for resumed sessions', async () => {467const mockServer = createMockLangModelServer();468const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;469470commitTestState(sessionStateService, 'existing-session');471const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'existing-session', false));472const stream = new MockChatResponseStream();473474await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);475476// Resumed session should use resume, not sessionId477expect(mockService.lastQueryOptions?.resume).toBe('existing-session');478expect(mockService.lastQueryOptions?.sessionId).toBeUndefined();479});480481it('passes effort in SDK options when reasoning effort is set in session state', async () => {482const mockServer = createMockLangModelServer();483const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;484485commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);486sessionStateService.setReasoningEffortForSession('test-session', 'low');487const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));488const stream = new MockChatResponseStream();489490await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);491492expect(mockService.lastQueryOptions?.effort).toBe('low');493});494495it('does not include effort in SDK options when reasoning effort is not set', async () => {496const mockServer = createMockLangModelServer();497const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;498499commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);500const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));501const stream = new MockChatResponseStream();502503await session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);504505expect(mockService.lastQueryOptions?.effort).toBeUndefined();506});507508it('restarts session when effort level changes', async () => {509const mockServer = createMockLangModelServer();510const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;511mockService.queryCallCount = 0;512513commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);514const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));515516// First request with no effort517const stream1 = new MockChatResponseStream();518await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);519expect(mockService.queryCallCount).toBe(1);520521// Change effort level522sessionStateService.setReasoningEffortForSession('test-session', 'high');523524// Second request should restart session (new query created)525const stream2 = new MockChatResponseStream();526await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);527expect(mockService.queryCallCount).toBe(2);528});529530it('does not restart session when effort level is unchanged', async () => {531const mockServer = createMockLangModelServer();532const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService;533mockService.queryCallCount = 0;534535commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID);536sessionStateService.setReasoningEffortForSession('test-session', 'medium');537const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));538539// First request540const stream1 = new MockChatResponseStream();541await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);542expect(mockService.queryCallCount).toBe(1);543544// Second request with same effort level545const stream2 = new MockChatResponseStream();546await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);547expect(mockService.queryCallCount).toBe(1);548});549});550551describe('ClaudeAgentManager - error handling', () => {552const store = new DisposableStore();553let instantiationService: IInstantiationService;554555beforeEach(() => {556const services = store.add(createExtensionUnitTestingServices());557const accessor = services.createTestingAccessor();558instantiationService = accessor.get(IInstantiationService);559});560561afterEach(() => {562store.clear();563vi.resetAllMocks();564});565566it('throws when session state has not been committed', async () => {567const manager = instantiationService.createInstance(ClaudeAgentManager);568const stream = new MockChatResponseStream();569570// Do NOT commit state - handleRequest should fail571const req = new TestChatRequest('Hello');572const result = await manager.handleRequest('no-state-session', req, stream, CancellationToken.None, true);573574// Should return an error result (the error is caught and streamed)575expect(result.errorDetails).toBeDefined();576});577});578579describe('ClaudeCodeSession - yield flow', () => {580const store = new DisposableStore();581let instantiationService: IInstantiationService;582let sessionStateService: IClaudeSessionStateService;583let mockService: MockClaudeCodeSdkService;584585beforeEach(() => {586const services = store.add(createExtensionUnitTestingServices());587const accessor = services.createTestingAccessor();588instantiationService = accessor.get(IInstantiationService);589sessionStateService = accessor.get(IClaudeSessionStateService);590mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;591mockService.queryCallCount = 0;592});593594afterEach(() => {595store.clear();596vi.resetAllMocks();597});598599it('yield completes the current request while session continues', async () => {600const mockServer = createMockLangModelServer();601commitTestState(sessionStateService, 'test-session');602const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));603604const stream1 = new MockChatResponseStream();605// yieldRequested is set before _processMessages runs (async session start),606// so the yield check triggers on the first dispatched message607const promise1 = session.invoke(createMockChatRequest('First'), stream1, () => true, CancellationToken.None);608await promise1;609610// Session should still be alive — send a second request611const stream2 = new MockChatResponseStream();612const promise2 = session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None);613await promise2;614615expect(stream2.output.join('\n')).toContain('Hello from mock!');616expect(mockService.queryCallCount).toBe(1);617});618619it('second request after yield uses priority now', async () => {620const mockServer = createMockLangModelServer();621commitTestState(sessionStateService, 'test-session');622const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));623624const stream1 = new MockChatResponseStream();625await session.invoke(createMockChatRequest('First'), stream1, () => true, CancellationToken.None);626627const stream2 = new MockChatResponseStream();628await session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None);629630// The second message yielded to the SDK should have priority 'now'631expect(mockService.receivedMessages.length).toBeGreaterThanOrEqual(2);632expect(mockService.receivedMessages[1].priority).toBe('now');633});634635it('multiple yield cycles work correctly', async () => {636const mockServer = createMockLangModelServer();637commitTestState(sessionStateService, 'test-session');638const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));639640// A → yield → B → yield → C641const streamA = new MockChatResponseStream();642await session.invoke(createMockChatRequest('A'), streamA, () => true, CancellationToken.None);643644const streamB = new MockChatResponseStream();645await session.invoke(createMockChatRequest('B'), streamB, () => true, CancellationToken.None);646647const streamC = new MockChatResponseStream();648await session.invoke(createMockChatRequest('C'), streamC, undefined, CancellationToken.None);649650expect(streamC.output.join('\n')).toContain('Hello from mock!');651expect(mockService.queryCallCount).toBe(1);652expect(mockService.receivedMessages).toHaveLength(3);653});654});655656describe('ClaudeCodeSession - settings change restart', () => {657const store = new DisposableStore();658let instantiationService: IInstantiationService;659let sessionStateService: IClaudeSessionStateService;660let mockService: MockClaudeCodeSdkService;661let mockFs: MockFileSystemService;662663beforeEach(() => {664const services = store.add(createExtensionUnitTestingServices());665const accessor = services.createTestingAccessor();666instantiationService = accessor.get(IInstantiationService);667sessionStateService = accessor.get(IClaudeSessionStateService);668mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;669mockService.queryCallCount = 0;670mockFs = accessor.get(IFileSystemService) as MockFileSystemService;671});672673afterEach(() => {674store.clear();675vi.resetAllMocks();676});677678it('restarts session when settings files change between requests', async () => {679const mockServer = createMockLangModelServer();680commitTestState(sessionStateService, 'test-session');681const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));682683// First request establishes the session and takes a settings snapshot684const stream1 = new MockChatResponseStream();685await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);686expect(mockService.queryCallCount).toBe(1);687688// Simulate a CLAUDE.md file being created (settings change)689const claudeMdUri = URI.joinPath(URI.file('/home/testuser'), '.claude', 'CLAUDE.md');690mockFs.mockFile(claudeMdUri, '# Instructions', 2000);691692// Second request should trigger settings change → restart (new query created)693const stream2 = new MockChatResponseStream();694await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);695expect(mockService.queryCallCount).toBe(2);696});697698it('uses resume after settings change restart', async () => {699const mockServer = createMockLangModelServer();700commitTestState(sessionStateService, 'test-session');701const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));702703// First request — new session704const stream1 = new MockChatResponseStream();705await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);706expect(mockService.lastQueryOptions?.sessionId).toBe('test-session');707708// Trigger settings change709const claudeMdUri = URI.joinPath(URI.file('/home/testuser'), '.claude', 'CLAUDE.md');710mockFs.mockFile(claudeMdUri, '# Instructions', 2000);711712// Second request — should use resume, not sessionId713const stream2 = new MockChatResponseStream();714await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);715expect(mockService.lastQueryOptions?.resume).toBe('test-session');716expect(mockService.lastQueryOptions?.sessionId).toBeUndefined();717});718719it('does not restart when settings files have not changed', async () => {720const mockServer = createMockLangModelServer();721commitTestState(sessionStateService, 'test-session');722const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));723724const stream1 = new MockChatResponseStream();725await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);726expect(mockService.queryCallCount).toBe(1);727728// No file changes — session should be reused729const stream2 = new MockChatResponseStream();730await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);731expect(mockService.queryCallCount).toBe(1);732});733});734735describe('ClaudeCodeSession - effort and tools restart', () => {736const store = new DisposableStore();737let instantiationService: IInstantiationService;738let sessionStateService: IClaudeSessionStateService;739let mockService: MockClaudeCodeSdkService;740741beforeEach(() => {742const services = store.add(createExtensionUnitTestingServices());743const accessor = services.createTestingAccessor();744instantiationService = accessor.get(IInstantiationService);745sessionStateService = accessor.get(IClaudeSessionStateService);746mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService;747mockService.queryCallCount = 0;748});749750afterEach(() => {751store.clear();752vi.resetAllMocks();753});754755it('uses resume after effort change restart', async () => {756const mockServer = createMockLangModelServer();757commitTestState(sessionStateService, 'test-session');758const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));759760// First request — new session761const stream1 = new MockChatResponseStream();762await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);763expect(mockService.lastQueryOptions?.sessionId).toBe('test-session');764765// Change effort766sessionStateService.setReasoningEffortForSession('test-session', 'high');767768// Restarted session should use resume769const stream2 = new MockChatResponseStream();770await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None);771expect(mockService.lastQueryOptions?.resume).toBe('test-session');772expect(mockService.lastQueryOptions?.effort).toBe('high');773});774775it('restarts session when MCP tools change', async () => {776const mockServer = createMockLangModelServer();777commitTestState(sessionStateService, 'test-session');778const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));779780// First request with no MCP tools781const stream1 = new MockChatResponseStream();782await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None);783expect(mockService.queryCallCount).toBe(1);784785// Second request with a new MCP tool786const stream2 = new MockChatResponseStream();787const mcpTool = { name: 'mcp-tool', source: new LanguageModelToolMCPSource('test-server', 'test-server', undefined) } as unknown as vscode.LanguageModelChatTool;788const reqWithTool: vscode.ChatRequest = {789prompt: 'Hello again',790references: [],791tools: new Map([[mcpTool, true]]),792id: 'test-request-2',793toolInvocationToken: {}794} as unknown as vscode.ChatRequest;795await session.invoke(reqWithTool, stream2, undefined, CancellationToken.None);796expect(mockService.queryCallCount).toBe(2);797});798799it('does not restart when MCP tools are unchanged', async () => {800const mockServer = createMockLangModelServer();801commitTestState(sessionStateService, 'test-session');802const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true));803804const mcpTool = { name: 'mcp-tool', source: new LanguageModelToolMCPSource('test-server', 'test-server', undefined) } as unknown as vscode.LanguageModelChatTool;805const makeReq = () => ({806prompt: 'Hello',807references: [],808tools: new Map([[mcpTool, true]]),809id: 'test-request',810toolInvocationToken: {}811} as unknown as vscode.ChatRequest);812813const stream1 = new MockChatResponseStream();814await session.invoke(makeReq(), stream1, undefined, CancellationToken.None);815expect(mockService.queryCallCount).toBe(1);816817const stream2 = new MockChatResponseStream();818await session.invoke(makeReq(), stream2, undefined, CancellationToken.None);819expect(mockService.queryCallCount).toBe(1);820});821});822823describe('ClaudeCodeSession - edge cases', () => {824const store = new DisposableStore();825let instantiationService: IInstantiationService;826let sessionStateService: IClaudeSessionStateService;827828beforeEach(() => {829const services = store.add(createExtensionUnitTestingServices());830const accessor = services.createTestingAccessor();831instantiationService = accessor.get(IInstantiationService);832sessionStateService = accessor.get(IClaudeSessionStateService);833});834835afterEach(() => {836store.clear();837vi.resetAllMocks();838});839840it('rejects in-flight requests when disposed', async () => {841const mockServer = createMockLangModelServer();842commitTestState(sessionStateService, 'test-session');843const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true);844845const stream = new MockChatResponseStream();846const promise = session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None);847848// Dispose immediately — the in-flight request should be rejected849session.dispose();850851await expect(promise).rejects.toThrow();852});853854it('rejects new requests after dispose', async () => {855const mockServer = createMockLangModelServer();856commitTestState(sessionStateService, 'test-session');857const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true);858session.dispose();859860const stream = new MockChatResponseStream();861await expect(862session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None)863).rejects.toThrow('Session disposed');864});865});866867868