diff --git a/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts b/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts index b855dc1b7..fbfd99b3e 100644 --- a/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts +++ b/src/extension/prompt/vscode-node/settingsEditorSearchServiceImpl.ts @@ -65,6 +65,23 @@ export class SettingsEditorSearchServiceImpl implements ISettingsEditorSearchSer } await this.embeddingIndex.loadIndexes(); + + if (embeddingResult.values.length === 0 || !embeddingResult.values[0]) { + progress.report({ + query, + kind: SettingsSearchResultKind.EMBEDDED, + settings: [] + }); + if (!options.embeddingsOnly) { + progress.report({ + query, + kind: SettingsSearchResultKind.LLM_RANKED, + settings: [] + }); + } + return; + } + const embeddingSettings: SettingListItem[] = this.embeddingIndex.settingsIndex.nClosestValues(embeddingResult.values[0], 25); if (token.isCancellationRequested) { progress.report(canceledBundle); diff --git a/src/extension/prompt/vscode-node/test/settingsEditorSearchServiceImpl.test.ts b/src/extension/prompt/vscode-node/test/settingsEditorSearchServiceImpl.test.ts new file mode 100644 index 000000000..67d9cf0ab --- /dev/null +++ b/src/extension/prompt/vscode-node/test/settingsEditorSearchServiceImpl.test.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { SettingsSearchResultKind, type Progress, type SettingsSearchProviderOptions, type SettingsSearchResult } from 'vscode'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { Embeddings, EmbeddingType, IEmbeddingsComputer } from '../../../../platform/embeddings/common/embeddingsComputer'; +import { ICombinedEmbeddingIndex } from '../../../../platform/embeddings/common/vscodeIndex'; +import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider'; +import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { createExtensionTestingServices } from '../../../test/vscode-node/services'; +import { SettingsEditorSearchServiceImpl } from '../settingsEditorSearchServiceImpl'; + +suite('SettingsEditorSearchServiceImpl test suite', function () { + let accessor: ITestingServicesAccessor; + let instaService: IInstantiationService; + let sandbox: sinon.SinonSandbox; + let service: SettingsEditorSearchServiceImpl; + let mockEmbeddingsComputer: sinon.SinonStubbedInstance; + let mockEmbeddingIndex: ICombinedEmbeddingIndex; + let mockAuthService: sinon.SinonStubbedInstance; + let mockEndpointProvider: IEndpointProvider; + + function createAccessor() { + const testingServiceCollection = createExtensionTestingServices(); + accessor = testingServiceCollection.createTestingAccessor(); + instaService = accessor.get(IInstantiationService); + } + + setup(() => { + sandbox = sinon.createSandbox(); + createAccessor(); + + // Create mock implementations using sinon + mockEmbeddingsComputer = { + _serviceBrand: undefined, + computeEmbeddings: sandbox.stub() + } as any; + + mockEmbeddingIndex = { + _serviceBrand: undefined, + loadIndexes: sandbox.stub().resolves(undefined), + settingsIndex: { + nClosestValues: sandbox.stub().returns([]) + } + } as any; + + mockAuthService = { + _serviceBrand: undefined, + getCopilotToken: sandbox.stub().resolves({ isFreeUser: true, isNoAuthUser: false }) + } as any; + + mockEndpointProvider = { + _serviceBrand: undefined + } as any; + + // Create the service manually with mocks + service = new SettingsEditorSearchServiceImpl( + mockAuthService as any, + mockEndpointProvider, + mockEmbeddingIndex, + mockEmbeddingsComputer as any, + instaService + ); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('handles empty embeddings result gracefully', async () => { + // Simulate computeEmbeddings returning an empty values array + const emptyEmbeddings: Embeddings = { + type: EmbeddingType.text3small_512, + values: [] + }; + mockEmbeddingsComputer.computeEmbeddings.resolves(emptyEmbeddings); + + const results: SettingsSearchResult[] = []; + const progress: Progress = { + report: (result: SettingsSearchResult) => results.push(result) + }; + + const options: SettingsSearchProviderOptions = { + limit: 10, + embeddingsOnly: false + }; + + await service.provideSettingsSearchResults('test query', options, progress, CancellationToken.None); + + // Verify that nClosestValues was NOT called (since values[0] is undefined) + assert.strictEqual((mockEmbeddingIndex.settingsIndex.nClosestValues as sinon.SinonStub).called, false); + + // Verify that we reported empty results for both EMBEDDED and LLM_RANKED + assert.strictEqual(results.length, 2); + assert.deepStrictEqual(results[0], { + query: 'test query', + kind: SettingsSearchResultKind.EMBEDDED, + settings: [] + }); + assert.deepStrictEqual(results[1], { + query: 'test query', + kind: SettingsSearchResultKind.LLM_RANKED, + settings: [] + }); + }); + + test('handles empty embeddings result with embeddingsOnly option', async () => { + // Simulate computeEmbeddings returning an empty values array + const emptyEmbeddings: Embeddings = { + type: EmbeddingType.text3small_512, + values: [] + }; + mockEmbeddingsComputer.computeEmbeddings.resolves(emptyEmbeddings); + + const results: SettingsSearchResult[] = []; + const progress: Progress = { + report: (result: SettingsSearchResult) => results.push(result) + }; + + const options: SettingsSearchProviderOptions = { + limit: 10, + embeddingsOnly: true + }; + + await service.provideSettingsSearchResults('test query', options, progress, CancellationToken.None); + + // Verify that nClosestValues was NOT called (since values[0] is undefined) + assert.strictEqual((mockEmbeddingIndex.settingsIndex.nClosestValues as sinon.SinonStub).called, false); + + // Verify that we only reported empty results for EMBEDDED (not LLM_RANKED) + assert.strictEqual(results.length, 1); + assert.deepStrictEqual(results[0], { + query: 'test query', + kind: SettingsSearchResultKind.EMBEDDED, + settings: [] + }); + }); + + test('calls nClosestValues when embeddings are available', async () => { + // Simulate computeEmbeddings returning a valid embedding + const validEmbeddings: Embeddings = { + type: EmbeddingType.text3small_512, + values: [{ + type: EmbeddingType.text3small_512, + value: [0.1, 0.2, 0.3] + }] + }; + mockEmbeddingsComputer.computeEmbeddings.resolves(validEmbeddings); + + const results: SettingsSearchResult[] = []; + const progress: Progress = { + report: (result: SettingsSearchResult) => results.push(result) + }; + + const options: SettingsSearchProviderOptions = { + limit: 10, + embeddingsOnly: true + }; + + await service.provideSettingsSearchResults('test query', options, progress, CancellationToken.None); + + // Verify that nClosestValues WAS called with the first embedding + const nClosestValuesStub = mockEmbeddingIndex.settingsIndex.nClosestValues as sinon.SinonStub; + assert.strictEqual(nClosestValuesStub.called, true); + assert.strictEqual(nClosestValuesStub.callCount, 1); + assert.deepStrictEqual(nClosestValuesStub.firstCall.args[0], validEmbeddings.values[0]); + assert.strictEqual(nClosestValuesStub.firstCall.args[1], 25); + + // Verify that we reported the result + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, SettingsSearchResultKind.EMBEDDED); + }); +}); diff --git a/src/util/common/test/shims/enums.ts b/src/util/common/test/shims/enums.ts index 017dc9463..d2d493ee7 100644 --- a/src/util/common/test/shims/enums.ts +++ b/src/util/common/test/shims/enums.ts @@ -65,4 +65,10 @@ export enum FileType { File = 1, Directory = 2, SymbolicLink = 64 +} + +export enum SettingsSearchResultKind { + EMBEDDED = 1, + LLM_RANKED = 2, + CANCELED = 3 } \ No newline at end of file diff --git a/src/util/common/test/shims/vscodeTypesShim.ts b/src/util/common/test/shims/vscodeTypesShim.ts index f8ffd41bd..2356415b0 100644 --- a/src/util/common/test/shims/vscodeTypesShim.ts +++ b/src/util/common/test/shims/vscodeTypesShim.ts @@ -20,7 +20,7 @@ import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/ import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; import { AISearchKeyword, ChatErrorLevel, ChatImageMimeType, ChatPrepareToolInvocationPart, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; -import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; +import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, SettingsSearchResultKind, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; import { NewSymbolName, NewSymbolNameTag, NewSymbolNameTriggerKind } from './newSymbolName'; import { TerminalShellExecutionCommandLineConfidence } from './terminal'; @@ -116,7 +116,8 @@ const shim: typeof vscodeTypes = { SymbolKind, SnippetString, SnippetTextEdit, - FileType + FileType, + SettingsSearchResultKind }; export = shim; diff --git a/src/vscodeTypes.ts b/src/vscodeTypes.ts index 198cb9c47..a503099aa 100644 --- a/src/vscodeTypes.ts +++ b/src/vscodeTypes.ts @@ -95,6 +95,7 @@ export import SymbolKind = vscode.SymbolKind; export import SnippetString = vscode.SnippetString; export import SnippetTextEdit = vscode.SnippetTextEdit; export import FileType = vscode.FileType; +export import SettingsSearchResultKind = vscode.SettingsSearchResultKind; export const l10n = { /**