Path: blob/main/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts
5252 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 { AsyncIterableSource, DeferredPromise, timeout } from '../../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';8import { DisposableStore } from '../../../../../base/common/lifecycle.js';9import { mock } from '../../../../../base/test/common/mock.js';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';11import { NullLogService } from '../../../../../platform/log/common/log.js';12import { ChatMessageRole, LanguageModelsService, IChatMessage, IChatResponsePart, ILanguageModelChatMetadata } from '../../common/languageModels.js';13import { IExtensionService, nullExtensionDescription } from '../../../../services/extensions/common/extensions.js';14import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../common/widget/input/modelPickerWidget.js';15import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';16import { TestStorageService } from '../../../../test/common/workbenchTestServices.js';17import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';18import { Event } from '../../../../../base/common/event.js';19import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';20import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js';21import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js';22import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';23import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js';2425suite('LanguageModels', function () {2627let languageModels: LanguageModelsService;2829const store = new DisposableStore();30const activationEvents = new Set<string>();3132setup(function () {3334languageModels = new LanguageModelsService(35new class extends mock<IExtensionService>() {36override activateByEvent(name: string) {37activationEvents.add(name);38return Promise.resolve();39}40},41new NullLogService(),42new TestStorageService(),43new MockContextKeyService(),44new class extends mock<ILanguageModelsConfigurationService>() {45override onDidChangeLanguageModelGroups = Event.None;46override getLanguageModelsProviderGroups() {47return [];48}49},50new class extends mock<IQuickInputService>() { },51new TestSecretStorageService(),52);5354languageModels.deltaLanguageModelChatProviderDescriptors([55{ vendor: 'test-vendor', displayName: 'Test Vendor', configuration: undefined, managementCommand: undefined, when: undefined },56{ vendor: 'actual-vendor', displayName: 'Actual Vendor', configuration: undefined, managementCommand: undefined, when: undefined }57], []);5859store.add(languageModels.registerLanguageModelProvider('test-vendor', {60onDidChange: Event.None,61provideLanguageModelChatInfo: async () => {62const modelMetadata = [63{64extension: nullExtensionDescription.identifier,65name: 'Pretty Name',66vendor: 'test-vendor',67family: 'test-family',68version: 'test-version',69modelPickerCategory: undefined,70id: 'test-id-1',71maxInputTokens: 100,72maxOutputTokens: 100,73isDefaultForLocation: {}74} satisfies ILanguageModelChatMetadata,75{76extension: nullExtensionDescription.identifier,77name: 'Pretty Name',78vendor: 'test-vendor',79family: 'test2-family',80version: 'test2-version',81modelPickerCategory: undefined,82id: 'test-id-12',83maxInputTokens: 100,84maxOutputTokens: 100,85isDefaultForLocation: {}86} satisfies ILanguageModelChatMetadata87];88const modelMetadataAndIdentifier = modelMetadata.map(m => ({89metadata: m,90identifier: m.id,91}));92return modelMetadataAndIdentifier;93},94sendChatRequest: async () => {95throw new Error();96},97provideTokenCount: async () => {98throw new Error();99}100}));101});102103teardown(function () {104languageModels.dispose();105activationEvents.clear();106store.clear();107});108109ensureNoDisposablesAreLeakedInTestSuite();110111test('empty selector returns all', async function () {112113const result1 = await languageModels.selectLanguageModels({});114assert.deepStrictEqual(result1.length, 2);115assert.deepStrictEqual(result1[0], 'test-id-1');116assert.deepStrictEqual(result1[1], 'test-id-12');117});118119test('selector with id works properly', async function () {120const result1 = await languageModels.selectLanguageModels({ id: 'test-id-1' });121assert.deepStrictEqual(result1.length, 1);122assert.deepStrictEqual(result1[0], 'test-id-1');123});124125test('no warning that a matching model was not found #213716', async function () {126const result1 = await languageModels.selectLanguageModels({ vendor: 'test-vendor' });127assert.deepStrictEqual(result1.length, 2);128129const result2 = await languageModels.selectLanguageModels({ vendor: 'test-vendor', family: 'FAKE' });130assert.deepStrictEqual(result2.length, 0);131});132133test('sendChatRequest returns a response-stream', async function () {134135store.add(languageModels.registerLanguageModelProvider('actual-vendor', {136onDidChange: Event.None,137provideLanguageModelChatInfo: async () => {138const modelMetadata = [139{140extension: nullExtensionDescription.identifier,141name: 'Pretty Name',142vendor: 'actual-vendor',143family: 'actual-family',144version: 'actual-version',145id: 'actual-lm',146maxInputTokens: 100,147maxOutputTokens: 100,148modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,149isDefaultForLocation: {}150} satisfies ILanguageModelChatMetadata151];152const modelMetadataAndIdentifier = modelMetadata.map(m => ({153metadata: m,154identifier: m.id,155}));156return modelMetadataAndIdentifier;157},158sendChatRequest: async (modelId: string, messages: IChatMessage[], _from: ExtensionIdentifier, _options: { [name: string]: any }, token: CancellationToken) => {159// const message = messages.at(-1);160161const defer = new DeferredPromise();162const stream = new AsyncIterableSource<IChatResponsePart>();163164(async () => {165while (!token.isCancellationRequested) {166stream.emitOne({ type: 'text', value: Date.now().toString() });167await timeout(10);168}169defer.complete(undefined);170})();171172return {173stream: stream.asyncIterable,174result: defer.p175};176},177provideTokenCount: async () => {178throw new Error();179}180}));181182// Register the extension point for the actual vendor183languageModels.deltaLanguageModelChatProviderDescriptors([184{ vendor: 'actual-vendor', displayName: 'Actual Vendor', configuration: undefined, managementCommand: undefined, when: undefined }185], []);186187const models = await languageModels.selectLanguageModels({ id: 'actual-lm' });188assert.ok(models.length === 1);189190const first = models[0];191192const cts = new CancellationTokenSource();193194const request = await languageModels.sendChatRequest(first, nullExtensionDescription.identifier, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: 'hello' }] }], {}, cts.token);195196assert.ok(request);197198cts.dispose(true);199200await request.result;201});202203test('when clause defaults to true when omitted', async function () {204const vendors = languageModels.getVendors();205// Both test-vendor and actual-vendor have no when clause, so they should be visible206assert.ok(vendors.length >= 2);207assert.ok(vendors.some(v => v.vendor === 'test-vendor'));208assert.ok(vendors.some(v => v.vendor === 'actual-vendor'));209});210});211212suite('LanguageModels - When Clause', function () {213214class TestContextKeyService extends MockContextKeyService {215override contextMatchesRules(rules: ContextKeyExpression): boolean {216if (!rules) {217return true;218}219// Simple evaluation based on stored keys220const keys = rules.keys();221for (const key of keys) {222const contextKey = this.getContextKeyValue(key);223// If the key exists and is truthy, the rule matches224if (contextKey) {225return true;226}227}228return false;229}230}231232let languageModelsWithWhen: LanguageModelsService;233let contextKeyService: TestContextKeyService;234235setup(function () {236contextKeyService = new TestContextKeyService();237contextKeyService.createKey('testKey', true);238239languageModelsWithWhen = new LanguageModelsService(240new class extends mock<IExtensionService>() {241override activateByEvent(name: string) {242return Promise.resolve();243}244},245new NullLogService(),246new TestStorageService(),247contextKeyService,248new class extends mock<ILanguageModelsConfigurationService>() {249override onDidChangeLanguageModelGroups = Event.None;250},251new class extends mock<IQuickInputService>() { },252new TestSecretStorageService(),253);254255languageModelsWithWhen.deltaLanguageModelChatProviderDescriptors([256{ vendor: 'visible-vendor', displayName: 'Visible Vendor', configuration: undefined, managementCommand: undefined, when: undefined },257{ vendor: 'conditional-vendor', displayName: 'Conditional Vendor', configuration: undefined, managementCommand: undefined, when: 'testKey' },258{ vendor: 'hidden-vendor', displayName: 'Hidden Vendor', configuration: undefined, managementCommand: undefined, when: 'falseKey' }259], []);260});261262teardown(function () {263languageModelsWithWhen.dispose();264});265266ensureNoDisposablesAreLeakedInTestSuite();267268test('when clause filters vendors correctly', async function () {269const vendors = languageModelsWithWhen.getVendors();270assert.strictEqual(vendors.length, 2);271assert.ok(vendors.some(v => v.vendor === 'visible-vendor'));272assert.ok(vendors.some(v => v.vendor === 'conditional-vendor'));273assert.ok(!vendors.some(v => v.vendor === 'hidden-vendor'));274});275276test('when clause evaluates to true when context key is true', async function () {277const vendors = languageModelsWithWhen.getVendors();278assert.ok(vendors.some(v => v.vendor === 'conditional-vendor'), 'conditional-vendor should be visible when testKey is true');279});280281test('when clause evaluates to false when context key is false', async function () {282const vendors = languageModelsWithWhen.getVendors();283assert.ok(!vendors.some(v => v.vendor === 'hidden-vendor'), 'hidden-vendor should be hidden when falseKey is false');284});285286});287288suite('LanguageModels - Model Picker Preferences Storage', function () {289290let languageModelsService: LanguageModelsService;291let storageService: TestStorageService;292const disposables = new DisposableStore();293294setup(async function () {295storageService = new TestStorageService();296297languageModelsService = new LanguageModelsService(298new class extends mock<IExtensionService>() {299override activateByEvent(name: string) {300return Promise.resolve();301}302},303new NullLogService(),304storageService,305new MockContextKeyService(),306new class extends mock<ILanguageModelsConfigurationService>() {307override onDidChangeLanguageModelGroups = Event.None;308override getLanguageModelsProviderGroups() {309return [];310}311},312new class extends mock<IQuickInputService>() { },313new TestSecretStorageService(),314);315316// Register vendor1 used in most tests317languageModelsService.deltaLanguageModelChatProviderDescriptors([318{ vendor: 'vendor1', displayName: 'Vendor 1', configuration: undefined, managementCommand: undefined, when: undefined }319], []);320321disposables.add(languageModelsService.registerLanguageModelProvider('vendor1', {322onDidChange: Event.None,323provideLanguageModelChatInfo: async () => {324return [{325metadata: {326extension: nullExtensionDescription.identifier,327name: 'Model 1',328vendor: 'vendor1',329family: 'family1',330version: '1.0',331id: 'vendor1/model1',332maxInputTokens: 100,333maxOutputTokens: 100,334modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,335isDefaultForLocation: {}336} satisfies ILanguageModelChatMetadata,337identifier: 'vendor1/model1'338}];339},340sendChatRequest: async () => { throw new Error(); },341provideTokenCount: async () => { throw new Error(); }342}));343344// Populate the model cache345await languageModelsService.selectLanguageModels({});346});347348teardown(function () {349languageModelsService.dispose();350disposables.clear();351});352353ensureNoDisposablesAreLeakedInTestSuite();354355test('fires onChange event when new model preferences are added', async function () {356// Listen for change event357let firedVendorId: string | undefined;358disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => firedVendorId = vendorId));359360// Add new preferences to storage - store() automatically triggers change event synchronously361const preferences = {362'vendor1/model1': true363};364storageService.store('chatModelPickerPreferences', JSON.stringify(preferences), StorageScope.PROFILE, StorageTarget.USER);365366// Verify change event was fired367assert.strictEqual(firedVendorId, 'vendor1', 'Should fire change event for vendor1');368369// Verify preference was updated370const model = languageModelsService.lookupLanguageModel('vendor1/model1');371assert.ok(model);372assert.strictEqual(model.isUserSelectable, true);373});374375test('fires onChange event when model preferences are removed', async function () {376// Set initial preference using the API377languageModelsService.updateModelPickerPreference('vendor1/model1', true);378379// Listen for change event380let firedVendorId: string | undefined;381disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => {382firedVendorId = vendorId;383}));384385// Remove preferences via storage API386const updatedPreferences = {};387storageService.store('chatModelPickerPreferences', JSON.stringify(updatedPreferences), StorageScope.PROFILE, StorageTarget.USER);388389// Verify change event was fired390assert.strictEqual(firedVendorId, 'vendor1', 'Should fire change event for vendor1 when preference removed');391392// Verify preference was removed393const model = languageModelsService.lookupLanguageModel('vendor1/model1');394assert.ok(model);395assert.strictEqual(model.isUserSelectable, undefined);396});397398test('fires onChange event when model preferences are updated', async function () {399// Set initial preference using the API400languageModelsService.updateModelPickerPreference('vendor1/model1', true);401402// Listen for change event403let firedVendorId: string | undefined;404disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => {405firedVendorId = vendorId;406}));407408// Update the preference value409const updatedPreferences = {410'vendor1/model1': false411};412storageService.store('chatModelPickerPreferences', JSON.stringify(updatedPreferences), StorageScope.PROFILE, StorageTarget.USER);413414// Verify change event was fired415assert.strictEqual(firedVendorId, 'vendor1', 'Should fire change event for vendor1 when preference updated');416417// Verify preference was updated418const model = languageModelsService.lookupLanguageModel('vendor1/model1');419assert.ok(model);420assert.strictEqual(model.isUserSelectable, false);421});422423test('only fires onChange event for affected vendors', async function () {424// Register vendor2425languageModelsService.deltaLanguageModelChatProviderDescriptors([426{ vendor: 'vendor2', displayName: 'Vendor 2', configuration: undefined, managementCommand: undefined, when: undefined }427], []);428429disposables.add(languageModelsService.registerLanguageModelProvider('vendor2', {430onDidChange: Event.None,431provideLanguageModelChatInfo: async () => {432return [{433metadata: {434extension: nullExtensionDescription.identifier,435name: 'Model 2',436vendor: 'vendor2',437family: 'family2',438version: '1.0',439id: 'vendor2/model2',440maxInputTokens: 100,441maxOutputTokens: 100,442modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY,443isDefaultForLocation: {}444} satisfies ILanguageModelChatMetadata,445identifier: 'vendor2/model2'446}];447},448sendChatRequest: async () => { throw new Error(); },449provideTokenCount: async () => { throw new Error(); }450}));451452await languageModelsService.selectLanguageModels({});453454// Set initial preferences using the API455languageModelsService.updateModelPickerPreference('vendor1/model1', true);456languageModelsService.updateModelPickerPreference('vendor2/model2', false);457458// Listen for change event459let firedVendorId: string | undefined;460disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => {461firedVendorId = vendorId;462}));463464// Update only vendor1 preference465const updatedPreferences = {466'vendor1/model1': false,467'vendor2/model2': false // unchanged468};469storageService.store('chatModelPickerPreferences', JSON.stringify(updatedPreferences), StorageScope.PROFILE, StorageTarget.USER);470471// Verify only vendor1 was affected472assert.strictEqual(firedVendorId, 'vendor1', 'Should only affect vendor1');473474// Verify preferences were updated correctly475const model1 = languageModelsService.lookupLanguageModel('vendor1/model1');476assert.ok(model1);477assert.strictEqual(model1.isUserSelectable, false, 'vendor1/model1 should be updated to false');478479const model2 = languageModelsService.lookupLanguageModel('vendor2/model2');480assert.ok(model2);481assert.strictEqual(model2.isUserSelectable, false, 'vendor2/model2 should remain false');482});483484test('does not fire onChange event when preferences are unchanged', async function () {485// Set initial preference using the API486languageModelsService.updateModelPickerPreference('vendor1/model1', true);487488// Listen for change event489let eventFired = false;490disposables.add(languageModelsService.onDidChangeLanguageModels(() => {491eventFired = true;492}));493494// Store the same preferences again495const initialPreferences = {496'vendor1/model1': true497};498storageService.store('chatModelPickerPreferences', JSON.stringify(initialPreferences), StorageScope.PROFILE, StorageTarget.USER);499500// Verify no event was fired501assert.strictEqual(eventFired, false, 'Should not fire event when preferences are unchanged');502503// Verify preference remains the same504const model = languageModelsService.lookupLanguageModel('vendor1/model1');505assert.ok(model);506assert.strictEqual(model.isUserSelectable, true);507});508509test('handles malformed JSON in storage gracefully', function () {510// Listen for change event511let eventFired = false;512disposables.add(languageModelsService.onDidChangeLanguageModels(() => {513eventFired = true;514}));515516// Store empty preferences - store() automatically triggers change event517storageService.store('chatModelPickerPreferences', '{}', StorageScope.PROFILE, StorageTarget.USER);518519// Verify no event was fired - empty preferences is valid and causes no changes520assert.strictEqual(eventFired, false, 'Should not fire event for empty preferences');521});522});523524suite('LanguageModels - Model Change Events', function () {525526let languageModelsService: LanguageModelsService;527let storageService: TestStorageService;528const disposables = new DisposableStore();529530setup(async function () {531storageService = new TestStorageService();532533languageModelsService = new LanguageModelsService(534new class extends mock<IExtensionService>() {535override activateByEvent(name: string) {536return Promise.resolve();537}538},539new NullLogService(),540storageService,541new MockContextKeyService(),542new class extends mock<ILanguageModelsConfigurationService>() {543override onDidChangeLanguageModelGroups = Event.None;544override getLanguageModelsProviderGroups() {545return [];546}547},548new class extends mock<IQuickInputService>() { },549new TestSecretStorageService(),550);551552// Register the vendor first553languageModelsService.deltaLanguageModelChatProviderDescriptors([554{ vendor: 'test-vendor', displayName: 'Test Vendor', configuration: undefined, managementCommand: undefined, when: undefined }555], []);556});557558teardown(function () {559languageModelsService.dispose();560disposables.clear();561});562563ensureNoDisposablesAreLeakedInTestSuite();564565test('fires onChange event when new models are added', async function () {566// Create a promise that resolves when the event fires567const eventPromise = new Promise<string>((resolve) => {568disposables.add(languageModelsService.onDidChangeLanguageModels((vendorId) => {569resolve(vendorId);570}));571});572573// Store a preference to trigger auto-resolution when provider is registered574storageService.store('chatModelPickerPreferences', JSON.stringify({ 'test-vendor/model1': true }), StorageScope.PROFILE, StorageTarget.USER);575576disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', {577onDidChange: Event.None,578provideLanguageModelChatInfo: async () => {579return [{580metadata: {581extension: nullExtensionDescription.identifier,582name: 'Model 1',583vendor: 'test-vendor',584family: 'family1',585version: '1.0',586id: 'model1',587maxInputTokens: 100,588maxOutputTokens: 100,589modelPickerCategory: undefined,590isDefaultForLocation: {}591} satisfies ILanguageModelChatMetadata,592identifier: 'test-vendor/model1'593}];594},595sendChatRequest: async () => { throw new Error(); },596provideTokenCount: async () => { throw new Error(); }597}));598599const firedVendorId = await eventPromise;600assert.strictEqual(firedVendorId, 'test-vendor', 'Should fire event when new models are added');601});602603test('does not fire onChange event when models are unchanged', async function () {604const models = [{605metadata: {606extension: nullExtensionDescription.identifier,607name: 'Model 1',608vendor: 'test-vendor',609family: 'family1',610version: '1.0',611id: 'model1',612maxInputTokens: 100,613maxOutputTokens: 100,614modelPickerCategory: undefined,615isDefaultForLocation: {}616} satisfies ILanguageModelChatMetadata,617identifier: 'test-vendor/model1'618}];619620let onDidChangeEmitter: any;621disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', {622onDidChange: (listener) => {623onDidChangeEmitter = { fire: () => listener() };624return { dispose: () => { } };625},626provideLanguageModelChatInfo: async () => models,627sendChatRequest: async () => { throw new Error(); },628provideTokenCount: async () => { throw new Error(); }629}));630631// Initial resolution632await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });633634// Listen for change event635let eventFired = false;636disposables.add(languageModelsService.onDidChangeLanguageModels(() => {637eventFired = true;638}));639// Trigger provider change with same models640onDidChangeEmitter.fire();641642// Call selectLanguageModels again - provider will return different models643await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });644assert.strictEqual(eventFired, false, 'Should not fire event when models are unchanged');645});646647test('fires onChange event when model metadata changes', async function () {648const initialModels = [{649metadata: {650extension: nullExtensionDescription.identifier,651name: 'Model 1',652vendor: 'test-vendor',653family: 'family1',654version: '1.0',655id: 'model1',656maxInputTokens: 100,657maxOutputTokens: 100,658modelPickerCategory: undefined,659isDefaultForLocation: {}660} satisfies ILanguageModelChatMetadata,661identifier: 'test-vendor/model1'662}];663664let currentModels = initialModels;665let onDidChangeEmitter: any;666disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', {667onDidChange: (listener) => {668onDidChangeEmitter = { fire: () => listener() };669return { dispose: () => { } };670},671provideLanguageModelChatInfo: async () => currentModels,672sendChatRequest: async () => { throw new Error(); },673provideTokenCount: async () => { throw new Error(); }674}));675676// Initial resolution677await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });678679// Create a promise that resolves when the event fires680const eventPromise = new Promise<void>((resolve) => {681disposables.add(languageModelsService.onDidChangeLanguageModels(() => {682resolve();683}));684});685686// Change model metadata (e.g., maxInputTokens)687currentModels = [{688metadata: {689...initialModels[0].metadata,690maxInputTokens: 200 // Changed from 100691},692identifier: 'test-vendor/model1'693}];694695onDidChangeEmitter.fire();696697await eventPromise;698assert.ok(true, 'Event fired when model metadata changed');699});700701test('fires onChange event when models are removed', async function () {702let currentModels = [{703metadata: {704extension: nullExtensionDescription.identifier,705name: 'Model 1',706vendor: 'test-vendor',707family: 'family1',708version: '1.0',709id: 'model1',710maxInputTokens: 100,711maxOutputTokens: 100,712modelPickerCategory: undefined,713isDefaultForLocation: {}714} satisfies ILanguageModelChatMetadata,715identifier: 'test-vendor/model1'716}];717718let onDidChangeEmitter: any;719disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', {720onDidChange: (listener) => {721onDidChangeEmitter = { fire: () => listener() };722return { dispose: () => { } };723},724provideLanguageModelChatInfo: async () => currentModels,725sendChatRequest: async () => { throw new Error(); },726provideTokenCount: async () => { throw new Error(); }727}));728729// Initial resolution730await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });731732// Create a promise that resolves when the event fires733const eventPromise = new Promise<void>((resolve) => {734disposables.add(languageModelsService.onDidChangeLanguageModels(() => {735resolve();736}));737});738739// Remove all models740currentModels = [];741742onDidChangeEmitter.fire();743744await eventPromise;745assert.ok(true, 'Event fired when models were removed');746});747748test('fires onChange event when new model is added to existing set', async function () {749let currentModels = [{750metadata: {751extension: nullExtensionDescription.identifier,752name: 'Model 1',753vendor: 'test-vendor',754family: 'family1',755version: '1.0',756id: 'model1',757maxInputTokens: 100,758maxOutputTokens: 100,759modelPickerCategory: undefined,760isDefaultForLocation: {}761} satisfies ILanguageModelChatMetadata,762identifier: 'test-vendor/model1'763}];764765let onDidChangeEmitter: any;766disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', {767onDidChange: (listener) => {768onDidChangeEmitter = { fire: () => listener() };769return { dispose: () => { } };770},771provideLanguageModelChatInfo: async () => currentModels,772sendChatRequest: async () => { throw new Error(); },773provideTokenCount: async () => { throw new Error(); }774}));775776// Initial resolution777await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });778779// Create a promise that resolves when the event fires780const eventPromise = new Promise<void>((resolve) => {781disposables.add(languageModelsService.onDidChangeLanguageModels(() => {782resolve();783}));784});785786// Add a new model787currentModels = [788...currentModels,789{790metadata: {791extension: nullExtensionDescription.identifier,792name: 'Model 2',793vendor: 'test-vendor',794family: 'family2',795version: '1.0',796id: 'model2',797maxInputTokens: 100,798maxOutputTokens: 100,799modelPickerCategory: undefined,800isDefaultForLocation: {}801} satisfies ILanguageModelChatMetadata,802identifier: 'test-vendor/model2'803}804];805806onDidChangeEmitter.fire();807808await eventPromise;809assert.ok(true, 'Event fired when new model was added');810});811812test('fires onChange event when models change without provider emitting change event', async function () {813let callCount = 0;814disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', {815onDidChange: Event.None, // Provider doesn't emit change events816provideLanguageModelChatInfo: async () => {817callCount++;818if (callCount === 1) {819// First call returns initial model820return [{821metadata: {822extension: nullExtensionDescription.identifier,823name: 'Model 1',824vendor: 'test-vendor',825family: 'family1',826version: '1.0',827id: 'model1',828maxInputTokens: 100,829maxOutputTokens: 100,830modelPickerCategory: undefined,831isDefaultForLocation: {}832} satisfies ILanguageModelChatMetadata,833identifier: 'test-vendor/model1'834}];835} else {836// Subsequent calls return different model837return [{838metadata: {839extension: nullExtensionDescription.identifier,840name: 'Model 2',841vendor: 'test-vendor',842family: 'family2',843version: '2.0',844id: 'model2',845maxInputTokens: 200,846maxOutputTokens: 200,847modelPickerCategory: undefined,848isDefaultForLocation: {}849} satisfies ILanguageModelChatMetadata,850identifier: 'test-vendor/model2'851}];852}853},854sendChatRequest: async () => { throw new Error(); },855provideTokenCount: async () => { throw new Error(); }856}));857858// Initial resolution859await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });860861// Listen for change event862let eventFired = false;863disposables.add(languageModelsService.onDidChangeLanguageModels(() => {864eventFired = true;865}));866867// Call selectLanguageModels again - provider will return different models868await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' });869870assert.strictEqual(eventFired, true, 'Should fire event when models change even without provider change event');871});872});873874suite('LanguageModels - Vendor Change Events', function () {875876let languageModelsService: LanguageModelsService;877const disposables = new DisposableStore();878879setup(function () {880languageModelsService = new LanguageModelsService(881new class extends mock<IExtensionService>() {882override activateByEvent(name: string) {883return Promise.resolve();884}885},886new NullLogService(),887new TestStorageService(),888new MockContextKeyService(),889new class extends mock<ILanguageModelsConfigurationService>() {890override onDidChangeLanguageModelGroups = Event.None;891override getLanguageModelsProviderGroups() {892return [];893}894},895new class extends mock<IQuickInputService>() { },896new TestSecretStorageService(),897);898});899900teardown(function () {901languageModelsService.dispose();902disposables.clear();903});904905ensureNoDisposablesAreLeakedInTestSuite();906907test('fires onDidChangeLanguageModelVendors when a vendor is added', async function () {908const eventPromise = new Promise<readonly string[]>((resolve) => {909disposables.add(languageModelsService.onDidChangeLanguageModelVendors(vendors => resolve(vendors)));910});911912languageModelsService.deltaLanguageModelChatProviderDescriptors([913{ vendor: 'added-vendor', displayName: 'Added Vendor', configuration: undefined, managementCommand: undefined, when: undefined }914], []);915916const vendors = await eventPromise;917assert.ok(vendors.includes('added-vendor'));918});919920test('fires onDidChangeLanguageModelVendors when a vendor is removed', async function () {921languageModelsService.deltaLanguageModelChatProviderDescriptors([922{ vendor: 'removed-vendor', displayName: 'Removed Vendor', configuration: undefined, managementCommand: undefined, when: undefined }923], []);924925const eventPromise = new Promise<readonly string[]>((resolve) => {926disposables.add(languageModelsService.onDidChangeLanguageModelVendors(vendors => resolve(vendors)));927});928929languageModelsService.deltaLanguageModelChatProviderDescriptors([], [930{ vendor: 'removed-vendor', displayName: 'Removed Vendor', configuration: undefined, managementCommand: undefined, when: undefined }931]);932933const vendors = await eventPromise;934assert.ok(vendors.includes('removed-vendor'));935});936937test('fires onDidChangeLanguageModelVendors when multiple vendors are added and removed', async function () {938// Add multiple vendors939const addEventPromise = new Promise<readonly string[]>((resolve) => {940disposables.add(languageModelsService.onDidChangeLanguageModelVendors(vendors => resolve(vendors)));941});942943languageModelsService.deltaLanguageModelChatProviderDescriptors([944{ vendor: 'vendor-a', displayName: 'Vendor A', configuration: undefined, managementCommand: undefined, when: undefined },945{ vendor: 'vendor-b', displayName: 'Vendor B', configuration: undefined, managementCommand: undefined, when: undefined }946], []);947948const addedVendors = await addEventPromise;949assert.ok(addedVendors.includes('vendor-a'));950assert.ok(addedVendors.includes('vendor-b'));951952// Remove one vendor953const removeEventPromise = new Promise<readonly string[]>((resolve) => {954disposables.add(languageModelsService.onDidChangeLanguageModelVendors(vendors => resolve(vendors)));955});956957languageModelsService.deltaLanguageModelChatProviderDescriptors([], [958{ vendor: 'vendor-a', displayName: 'Vendor A', configuration: undefined, managementCommand: undefined, when: undefined }959]);960961const removedVendors = await removeEventPromise;962assert.ok(removedVendors.includes('vendor-a'));963});964965test('does not fire onDidChangeLanguageModelVendors when no vendors are added or removed', async function () {966// Add initial vendor967languageModelsService.deltaLanguageModelChatProviderDescriptors([968{ vendor: 'stable-vendor', displayName: 'Stable Vendor', configuration: undefined, managementCommand: undefined, when: undefined }969], []);970971// Listen for change event972let eventFired = false;973disposables.add(languageModelsService.onDidChangeLanguageModelVendors(() => {974eventFired = true;975}));976977// Call with empty arrays - should not fire event978languageModelsService.deltaLanguageModelChatProviderDescriptors([], []);979980assert.strictEqual(eventFired, false, 'Should not fire event when vendor list is unchanged');981});982});983984985