🚫 fix: Hide Empty Agents Endpoint from Model Selector (#13624)

* fix: prevent empty agents endpoint selection

* fix: sort endpoint item imports
This commit is contained in:
Danny Avila 2026-06-09 12:10:41 -04:00 committed by GitHub
parent fd4728232c
commit 6fc72e5dfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 143 additions and 4 deletions

View file

@ -11,6 +11,13 @@ const agentsEndpoint: Endpoint = {
searchAliases: ['agent marketplace', 'marketplace'],
};
const disabledAgentsEndpoint: Endpoint = {
value: 'agents',
label: 'My Agents',
hasModels: false,
icon: null,
};
describe('model selector utilities', () => {
it('matches endpoint search aliases', () => {
const results = filterItems([agentsEndpoint], 'marketplace', undefined, undefined);
@ -31,4 +38,9 @@ describe('model selector utilities', () => {
const results = filterItems([agentsEndpoint], 'tienda', undefined, undefined, localize);
expect(results).toEqual([agentsEndpoint]);
});
it('does not match agents when there are no selectable agent options', () => {
const results = filterItems([disabledAgentsEndpoint], 'my agents', undefined, undefined);
expect(results).toEqual([]);
});
});

View file

@ -7,10 +7,10 @@ import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem, CustomMenuSeparator } from '../CustomMenu';
import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace';
import { filterModels, shouldRenderEndpointOption } from '../utils';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
import { filterModels } from '../utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -182,6 +182,10 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
const isEndpointSelected = !selectedSpec && selectedEndpoint === endpoint.value;
if (!shouldRenderEndpointOption(endpoint)) {
return null;
}
if (endpoint.hasModels) {
const placeholder =
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)

View file

@ -7,6 +7,7 @@ import type { Endpoint } from '~/common';
import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import { shouldRenderEndpointOption } from '../utils';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
@ -103,6 +104,10 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
} else {
// For an endpoint item
const endpoint = item as Endpoint;
if (!shouldRenderEndpointOption(endpoint)) {
return null;
}
if (endpoint.hasModels) {
const lowerQuery = searchValue.toLowerCase();
const endpointMatches = endpoint.label.toLowerCase().includes(lowerQuery);

View file

@ -0,0 +1,80 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { Endpoint, SelectedValues } from '~/common';
import { EndpointItem } from '../EndpointItem';
const mockHandleSelectEndpoint = jest.fn();
const mockHandleOpenKeyDialog = jest.fn();
const mockSetEndpointSearchValue = jest.fn();
let mockSelectedValues: SelectedValues = { endpoint: '', model: '', modelSpec: '' };
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({
useModelSelectorContext: () => ({
agentsMap: undefined,
assistantsMap: undefined,
modelSpecs: [],
selectedValues: mockSelectedValues,
endpointSearchValues: {},
handleOpenKeyDialog: mockHandleOpenKeyDialog,
handleSelectEndpoint: mockHandleSelectEndpoint,
setEndpointSearchValue: mockSetEndpointSearchValue,
endpointRequiresUserKey: () => false,
}),
}));
jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => {
const React = jest.requireActual<typeof import('react')>('react');
return {
CustomMenu: ({ children, label }: { children?: React.ReactNode; label?: React.ReactNode }) =>
React.createElement('div', null, label, children),
CustomMenuItem: React.forwardRef(function MockMenuItem(
{ children, ...rest }: { children?: React.ReactNode },
ref: React.Ref<HTMLButtonElement>,
) {
return React.createElement('button', { ref, type: 'button', ...rest }, children);
}),
CustomMenuSeparator: () => React.createElement('hr'),
};
});
const disabledAgentsEndpoint: Endpoint = {
value: 'agents',
label: 'My Agents',
hasModels: false,
icon: null,
};
const customEndpoint: Endpoint = {
value: 'custom',
label: 'Custom',
hasModels: false,
icon: null,
};
describe('EndpointItem', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSelectedValues = { endpoint: '', model: '', modelSpec: '' };
});
it('does not render agents as a leaf endpoint when no selectable rows exist', () => {
render(<EndpointItem endpoint={disabledAgentsEndpoint} endpointIndex={0} />);
expect(screen.queryByText('My Agents')).not.toBeInTheDocument();
expect(mockHandleSelectEndpoint).not.toHaveBeenCalled();
});
it('keeps non-agent endpoints without models selectable', () => {
render(<EndpointItem endpoint={customEndpoint} endpointIndex={0} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom' }));
expect(mockHandleSelectEndpoint).toHaveBeenCalledWith(customEndpoint);
});
});

View file

@ -70,6 +70,13 @@ const agentsMarketplaceEndpoint: Endpoint = {
icon: null,
};
const disabledAgentsEndpoint: Endpoint = {
value: 'agents',
label: 'My Agents',
hasModels: false,
icon: null,
};
describe('SearchResults', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -140,4 +147,18 @@ describe('SearchResults', () => {
expect(mockNavigate).toHaveBeenCalledWith('/agents');
expect(mockHandleSelectModel).not.toHaveBeenCalled();
});
it('does not render agents as a selectable endpoint when marketplace and agent rows are unavailable', () => {
mockSelectedValues = { endpoint: '', model: '', modelSpec: '' };
render(
<SearchResults
results={[disabledAgentsEndpoint]}
localize={localize}
searchValue="my agents"
/>,
);
expect(screen.queryByRole('menuitem', { name: 'My Agents' })).not.toBeInTheDocument();
expect(mockHandleSelectEndpoint).not.toHaveBeenCalled();
});
});

View file

@ -16,6 +16,7 @@ export function filterItems<
label: string;
name?: string;
value?: string;
hasModels?: boolean;
models?: Array<{ name: string; isGlobal?: boolean }>;
searchAliases?: string[];
showMarketplace?: boolean;
@ -33,6 +34,10 @@ export function filterItems<
}
return items.filter((item) => {
if (!shouldRenderEndpointOption(item)) {
return false;
}
const itemMatches =
item.label.toLowerCase().includes(searchTermLower) ||
(item.name && item.name.toLowerCase().includes(searchTermLower)) ||
@ -76,6 +81,13 @@ export function filterItems<
});
}
export function shouldRenderEndpointOption(endpoint: {
value?: string;
hasModels?: boolean;
}): boolean {
return !isAgentsEndpoint(endpoint.value) || endpoint.hasModels === true;
}
export function filterModels(
endpoint: Endpoint,
models: string[],

View file

@ -84,7 +84,7 @@ export const useEndpoints = ({
);
const mappedEndpoints: Endpoint[] = useMemo(() => {
return filteredEndpoints.map((ep) => {
return filteredEndpoints.reduce<Endpoint[]>((acc, ep) => {
const endpointType = getEndpointField(endpointsConfig, ep, 'type');
const iconKey = getIconKey({ endpoint: ep, endpointsConfig, endpointType });
const Icon = icons[iconKey];
@ -96,6 +96,10 @@ export const useEndpoints = ({
ep !== EModelEndpoint.agents &&
(modelsQuery.data?.[ep]?.length ?? 0) > 0);
if (ep === EModelEndpoint.agents && !hasModels) {
return acc;
}
// Base result object with formatted default icon
const result: Endpoint = {
value: ep,
@ -185,8 +189,9 @@ export const useEndpoints = ({
}));
}
return result;
});
acc.push(result);
return acc;
}, []);
}, [
agents,
assistants,