From e45ed3dfd50ccf55be97d4768728136cd5f79966 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 30 Jun 2026 12:00:32 -0700 Subject: [PATCH] Agent Host changes for agents/investigate-unit-test-failure-root-cause-898cd05d --- .../endpoint/node/copilotChatEndpoint.ts | 12 +- .../test/node/copilotChatEndpoint.spec.ts | 143 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts diff --git a/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts index 3f1f0f5684c..17306f5c5fd 100644 --- a/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts @@ -75,7 +75,17 @@ export class CopilotUtilitySmallChatEndpoint { static readonly capiFamily: string = CHAT_MODEL.GPT4OMINI; static async resolve(modelFetcher: IModelMetadataFetcher, instantiationService: IInstantiationService): Promise { - const modelMetadata = await modelFetcher.getChatModelFromCapiFamily(CopilotUtilitySmallChatEndpoint.capiFamily); + let modelMetadata: IChatModelInformation; + try { + modelMetadata = await modelFetcher.getChatModelFromCapiFamily(CopilotUtilitySmallChatEndpoint.capiFamily); + } catch { + // The small family is selected client-side and may be absent from a + // given user's CAPI `/models` response (plan/region/rollout differences, + // or a server-side rename/removal). Fall back to the API-marked base + // utility model rather than letting the lookup throw through every + // `copilot-utility-small` caller. + modelMetadata = await modelFetcher.getCopilotUtilityModel(); + } return instantiationService.createInstance(CopilotChatEndpoint, modelMetadata); } } diff --git a/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts new file mode 100644 index 00000000000..5a344c83334 --- /dev/null +++ b/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { LanguageModelChat } from 'vscode'; +import { describe, expect, test } from 'vitest'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { CHAT_MODEL } from '../../../configuration/common/configurationService'; +import { IChatModelInformation } from '../../common/endpointProvider'; +import { IModelMetadataFetcher } from '../../node/modelMetadataFetcher'; +import { CopilotUtilityChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../node/copilotChatEndpoint'; + +/** + * Builds a minimal valid chat model record as it would appear in the + * hydrated CAPI `/models` response. + */ +function chatModel(id: string, family: string, isChatFallback = false): IChatModelInformation { + return { + id, + name: id, + version: '1.0', + model_picker_enabled: false, + is_chat_default: false, + is_chat_fallback: isChatFallback, + capabilities: { + type: 'chat', + family, + tokenizer: 'o200k_base' as any, + limits: { max_prompt_tokens: 8192, max_output_tokens: 4096 }, + supports: {}, + }, + } as unknown as IChatModelInformation; +} + +/** + * A fake `IModelMetadataFetcher` that mimics the real one's lookup semantics + * against an in-memory set of models, including the exact throw at + * `modelMetadataFetcher.ts:190` when a CAPI family is absent. + */ +class FakeModelMetadataFetcher implements IModelMetadataFetcher { + private readonly _byFamily = new Map(); + private _utilityModel: IChatModelInformation | undefined; + + constructor(models: IChatModelInformation[]) { + for (const model of models) { + const family = model.capabilities.family; + const list = this._byFamily.get(family) ?? []; + list.push(model); + this._byFamily.set(family, list); + if (model.is_chat_fallback) { + this._utilityModel = model; + } + } + } + + onDidModelsRefresh = (() => { /* noop */ }) as any; + + async getAllCompletionModels(): Promise { return []; } + async getAllChatModels(): Promise { + return Array.from(this._byFamily.values()).flat(); + } + async getChatModelFromApiModel(_model: LanguageModelChat): Promise { return undefined; } + async getEmbeddingsModel(): Promise { throw new Error('not implemented'); } + + async getCopilotUtilityModel(): Promise { + if (!this._utilityModel) { + throw new Error('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)'); + } + return this._utilityModel; + } + + async getChatModelFromCapiFamily(family: string): Promise { + const resolved = this._byFamily.get(family)?.[0]; + if (!resolved) { + throw new Error(`Unable to resolve chat model with CAPI family selection: ${family}`); + } + return resolved; + } +} + +/** + * A fake instantiation service whose `createInstance` records the model + * metadata it was handed, so the test can assert which model the resolver + * ultimately selected without building the full `CopilotChatEndpoint`. + */ +function fakeInstantiationService(): { service: IInstantiationService; lastModel: () => IChatModelInformation | undefined } { + let last: IChatModelInformation | undefined; + const service = { + createInstance: (_ctor: unknown, modelMetadata: IChatModelInformation) => { + last = modelMetadata; + return { model: modelMetadata.id } as any; + }, + } as unknown as IInstantiationService; + return { service, lastModel: () => last }; +} + +describe('CopilotUtilitySmallChatEndpoint.resolve (issue #321184)', () => { + test('falls back to the base utility model when the gpt-4o-mini family is absent from /models', async () => { + // CAPI `/models` for this user contains a base chat-fallback model, but + // no model in the client-selected small family (gpt-4o-mini). This mirrors + // the plan/region/rollout differences reported in issue #321184. + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + // Sanity: the small family really is unresolvable for this user. + await expect(fetcher.getChatModelFromCapiFamily(CHAT_MODEL.GPT4OMINI)).rejects.toThrow( + 'Unable to resolve chat model with CAPI family selection: gpt-4o-mini' + ); + + // The resolver must degrade gracefully to the base utility model rather + // than letting the lookup throw through every copilot-utility-small caller. + await CopilotUtilitySmallChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4.1'); + }); + + test('uses the gpt-4o-mini family when it is present', async () => { + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4o-mini', CHAT_MODEL.GPT4OMINI), + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + await CopilotUtilitySmallChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4o-mini'); + }); + + test('sibling CopilotUtilityChatEndpoint.resolve already degrades gracefully', async () => { + // The sibling resolver is unaffected because it uses getCopilotUtilityModel(). + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + await CopilotUtilityChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4.1'); + }); +});