Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.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 assert from 'assert';6import { Emitter, Event } from '../../../../../base/common/event.js';7import { DisposableStore } from '../../../../../base/common/lifecycle.js';8import { observableValue } from '../../../../../base/common/observable.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';10import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';11import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';12import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';13import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';14import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';15import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE } from '../../../../services/sessions/common/session.js';16import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';17import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';18import { getAvailableModels, modelPickerStorageKey, SessionModelPicker } from '../../browser/copilotChatSessionsActions.js';1920function makeModel(id: string, sessionType: string): ILanguageModelChatMetadataAndIdentifier {21return {22identifier: id,23metadata: { targetChatSessionType: sessionType } as ILanguageModelChatMetadata,24};25}2627function stubServices(28disposables: DisposableStore,29opts?: {30models?: ILanguageModelChatMetadataAndIdentifier[];31activeSession?: Partial<IActiveSession>;32storedEntries?: Map<string, string>;33setModelSpy?: (sessionId: string, modelId: string) => void;34},35): { instantiationService: TestInstantiationService; storage: Map<string, string>; activeSession: ReturnType<typeof observableValue<IActiveSession | undefined>>; fireLanguageModelsChanged: () => void } {36const instantiationService = disposables.add(new TestInstantiationService());37const models = opts?.models ?? [];38const storage = opts?.storedEntries ?? new Map<string, string>();3940const activeSession = opts?.activeSession41? observableValue<IActiveSession | undefined>('activeSession', opts.activeSession as IActiveSession)42: observableValue<IActiveSession | undefined>('activeSession', undefined);4344const setModelSpy = opts?.setModelSpy ?? (() => { });4546const onDidChangeLanguageModelsEmitter = disposables.add(new Emitter<{ added?: readonly { identifier: string }[]; removed?: readonly string[] }>());4748instantiationService.stub(ILanguageModelsService, {49onDidChangeLanguageModels: onDidChangeLanguageModelsEmitter.event,50getLanguageModelIds: () => models.map(m => m.identifier),51lookupLanguageModel: (id: string) => models.find(m => m.identifier === id)?.metadata,52} as Partial<ILanguageModelsService>);5354instantiationService.stub(IStorageService, {55get: (key: string, _scope: StorageScope) => storage.get(key),56store: (key: string, value: string, _scope: StorageScope, _target: StorageTarget) => { storage.set(key, value); },57} as Partial<IStorageService>);5859const provider: Partial<ISessionsProvider> = {60id: 'default-copilot',61setModel: setModelSpy,62};6364instantiationService.stub(ISessionsManagementService, {65activeSession,66} as unknown as ISessionsManagementService);6768instantiationService.stub(ISessionsProvidersService, {69onDidChangeProviders: Event.None,70getProviders: () => [provider as ISessionsProvider],71} as Partial<ISessionsProvidersService>);7273// Stub IInstantiationService so SessionModelPicker can call createInstance for ModelPickerActionItem74instantiationService.stub(IInstantiationService, instantiationService);7576return { instantiationService, storage, activeSession, fireLanguageModelsChanged: () => onDidChangeLanguageModelsEmitter.fire({}) };77}7879suite('modelPickerStorageKey', () => {80ensureNoDisposablesAreLeakedInTestSuite();8182test('produces per-session-type keys', () => {83assert.strictEqual(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE), `sessions.modelPicker.${COPILOT_CLI_SESSION_TYPE}.selectedModelId`);84assert.strictEqual(modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE), `sessions.modelPicker.${CLAUDE_CODE_SESSION_TYPE}.selectedModelId`);85});86});8788suite('getAvailableModels', () => {89const disposables = new DisposableStore();9091teardown(() => disposables.clear());92ensureNoDisposablesAreLeakedInTestSuite();9394test('returns empty when no active session', () => {95const models = [makeModel('model-1', COPILOT_CLI_SESSION_TYPE)];96const { instantiationService } = stubServices(disposables, { models });97const languageModelsService = instantiationService.get(ILanguageModelsService);98const sessionsManagementService = instantiationService.get(ISessionsManagementService);99const result = getAvailableModels(languageModelsService, sessionsManagementService);100assert.deepStrictEqual(result, []);101});102103test('filters models by session type', () => {104const models = [105makeModel('cli-model', COPILOT_CLI_SESSION_TYPE),106makeModel('cloud-model', 'copilot-cloud'),107makeModel('claude-model', CLAUDE_CODE_SESSION_TYPE),108];109const { instantiationService } = stubServices(disposables, {110models,111activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE },112});113const languageModelsService = instantiationService.get(ILanguageModelsService);114const sessionsManagementService = instantiationService.get(ISessionsManagementService);115const result = getAvailableModels(languageModelsService, sessionsManagementService);116assert.deepStrictEqual(result, [models[2]]);117});118});119120suite('SessionModelPicker', () => {121const disposables = new DisposableStore();122123teardown(() => disposables.clear());124ensureNoDisposablesAreLeakedInTestSuite();125126test('stores selected model under session-type-scoped key', () => {127const models = [makeModel('model-1', CLAUDE_CODE_SESSION_TYPE)];128const { instantiationService, storage } = stubServices(disposables, {129models,130activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE },131});132// Creating the picker triggers initModel which calls setModel for the first available model133disposables.add(instantiationService.createInstance(SessionModelPicker));134assert.strictEqual(storage.get(modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE)), 'model-1');135assert.strictEqual(storage.has(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), false);136});137138test('calls provider.setModel on init', () => {139const calls: { sessionId: string; modelId: string }[] = [];140const models = [makeModel('model-1', CLAUDE_CODE_SESSION_TYPE)];141const { instantiationService } = stubServices(disposables, {142models,143activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE },144setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }),145});146disposables.add(instantiationService.createInstance(SessionModelPicker));147assert.ok(calls.some(c => c.sessionId === 'sess-1' && c.modelId === 'model-1'));148});149150test('remembers model per session type from storage', () => {151const models = [makeModel('model-a', CLAUDE_CODE_SESSION_TYPE), makeModel('model-b', CLAUDE_CODE_SESSION_TYPE)];152const storedEntries = new Map([[modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE), 'model-b']]);153const calls: { sessionId: string; modelId: string }[] = [];154const { instantiationService } = stubServices(disposables, {155models,156activeSession: { providerId: 'default-copilot', sessionId: 'sess-1', sessionType: CLAUDE_CODE_SESSION_TYPE },157storedEntries,158setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }),159});160disposables.add(instantiationService.createInstance(SessionModelPicker));161// Should pick model-b (remembered) instead of model-a (first)162assert.ok(calls.some(c => c.modelId === 'model-b'));163});164165test('does not throw when no active session', () => {166const { instantiationService } = stubServices(disposables);167assert.doesNotThrow(() => disposables.add(instantiationService.createInstance(SessionModelPicker)));168});169170test('different session types use independent storage keys', () => {171const cliModels = [makeModel('cli-m', COPILOT_CLI_SESSION_TYPE)];172const claudeModels = [makeModel('claude-m', CLAUDE_CODE_SESSION_TYPE)];173const allModels = [...cliModels, ...claudeModels];174175const { instantiationService, storage, activeSession } = stubServices(disposables, {176models: allModels,177activeSession: { providerId: 'default-copilot', sessionId: 's1', sessionType: COPILOT_CLI_SESSION_TYPE },178});179disposables.add(instantiationService.createInstance(SessionModelPicker));180assert.strictEqual(storage.get(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), 'cli-m');181182// Switch session type183activeSession.set({ providerId: 'default-copilot', sessionId: 's2', sessionType: CLAUDE_CODE_SESSION_TYPE } as IActiveSession, undefined);184185assert.strictEqual(storage.get(modelPickerStorageKey(CLAUDE_CODE_SESSION_TYPE)), 'claude-m');186// CLI key should still be intact187assert.strictEqual(storage.get(modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE)), 'cli-m');188});189190test('propagates selected model to a new session of the same type (#313385)', () => {191const models = [makeModel('cli-a', COPILOT_CLI_SESSION_TYPE), makeModel('cli-b', COPILOT_CLI_SESSION_TYPE)];192const storedEntries = new Map([[modelPickerStorageKey(COPILOT_CLI_SESSION_TYPE), 'cli-b']]);193const calls: { sessionId: string; modelId: string }[] = [];194const { instantiationService, activeSession } = stubServices(disposables, {195models,196activeSession: { providerId: 'default-copilot', sessionId: 's1', sessionType: COPILOT_CLI_SESSION_TYPE },197storedEntries,198setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }),199});200disposables.add(instantiationService.createInstance(SessionModelPicker));201// Initial session receives the remembered model.202assert.ok(calls.some(c => c.sessionId === 's1' && c.modelId === 'cli-b'));203204// Switch to a new session of the same type (e.g. user picked a different repo).205activeSession.set({ providerId: 'default-copilot', sessionId: 's2', sessionType: COPILOT_CLI_SESSION_TYPE } as IActiveSession, undefined);206207// The new session must receive the same model so the request isn't sent with the default.208assert.ok(calls.some(c => c.sessionId === 's2' && c.modelId === 'cli-b'));209});210211test('does not re-push model to the same session when language models change', () => {212const models = [makeModel('cli-a', COPILOT_CLI_SESSION_TYPE)];213const calls: { sessionId: string; modelId: string }[] = [];214const { instantiationService, fireLanguageModelsChanged } = stubServices(disposables, {215models,216activeSession: { providerId: 'default-copilot', sessionId: 's1', sessionType: COPILOT_CLI_SESSION_TYPE },217setModelSpy: (sessionId, modelId) => calls.push({ sessionId, modelId }),218});219disposables.add(instantiationService.createInstance(SessionModelPicker));220const initialCallCount = calls.filter(c => c.sessionId === 's1').length;221assert.ok(initialCallCount > 0, 'expected initial setModel to fire');222223// Re-fire language-models-changed multiple times. The active session and224// selected model haven't changed, so the provider must not be re-notified.225fireLanguageModelsChanged();226fireLanguageModelsChanged();227228assert.strictEqual(calls.filter(c => c.sessionId === 's1').length, initialCallCount);229});230});231232233