diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts
index 91692efab3..b5e6aefb37 100644
--- a/client/src/common/agents-types.ts
+++ b/client/src/common/agents-types.ts
@@ -10,8 +10,6 @@ import type {
} from 'librechat-data-provider';
import type { OptionWithIcon, ExtendedFile } from './types';
-export type AgentQueryResult = { found: true; agent: Agent } | { found: false };
-
export type TAgentOption = OptionWithIcon &
Agent & {
knowledge_files?: Array<[string, ExtendedFile]>;
diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx
index d36fb2de71..a52354ef20 100644
--- a/client/src/components/Nav/Favorites/FavoritesList.tsx
+++ b/client/src/components/Nav/Favorites/FavoritesList.tsx
@@ -1,13 +1,12 @@
import React, { useRef, useCallback, useMemo, useEffect } from 'react';
-import { LayoutGrid } from 'lucide-react';
import { useRecoilValue } from 'recoil';
+import { LayoutGrid } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import { Skeleton } from '@librechat/client';
import { useNavigate } from 'react-router-dom';
import { useQueries } from '@tanstack/react-query';
-import { QueryKeys, dataService } from 'librechat-data-provider';
+import { QueryKeys, EModelEndpoint, dataService } from 'librechat-data-provider';
import type { Agent, TEndpointsConfig, TModelSpec } from 'librechat-data-provider';
-import type { AgentQueryResult } from '~/common';
import {
useGetConversation,
useFavorites,
@@ -21,9 +20,21 @@ import useSelectMention from '~/hooks/Input/useSelectMention';
import FavoriteItem from './FavoriteItem';
import store from '~/store';
+/** A 404/403 from getAgentById means the agent is gone or inaccessible; other errors are transient. */
+const isMissingAgentError = (error: unknown): boolean => {
+ if (error && typeof error === 'object' && 'response' in error) {
+ const status = (error as { response?: { status?: number } }).response?.status;
+ return status === 404 || status === 403;
+ }
+ return false;
+};
+
/** Height intentionally matches FavoriteItem (px-3 py-2 + h-5 icon) to keep the CellMeasurerCache valid across the isAgentsLoading transition. */
const FavoriteItemSkeleton = () => (
-
+
@@ -133,7 +144,8 @@ export default function FavoritesList({
const { newConversation } = useNewConvo();
const assistantsMap = useAssistantsMapContext();
const agentsMap = useAgentsMapContext();
- const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
+ const { data: endpointsConfig = {} as TEndpointsConfig, isLoading: isEndpointsLoading } =
+ useGetEndpointsQuery();
const { data: startupConfig } = useGetStartupConfig();
const modelSpecs = useMemo(
@@ -213,45 +225,43 @@ export default function FavoritesList({
[safeFavorites],
);
- const missingAgentIds = useMemo(() => {
- if (agentsMap === undefined) {
+ const agentsEndpointEnabled = !!endpointsConfig?.[EModelEndpoint.agents];
+
+ const agentIdsToFetch = useMemo(() => {
+ if (!agentsEndpointEnabled) {
return [];
}
+ if (agentsMap === undefined) {
+ return allAgentIds;
+ }
return allAgentIds.filter((id) => !agentsMap[id]);
- }, [allAgentIds, agentsMap]);
+ }, [allAgentIds, agentsMap, agentsEndpointEnabled]);
- const missingAgentQueries = useQueries({
- queries: missingAgentIds.map((agentId) => ({
+ const agentQueries = useQueries({
+ queries: agentIdsToFetch.map((agentId) => ({
queryKey: [QueryKeys.agent, agentId],
- queryFn: async (): Promise
=> {
- try {
- const agent = await dataService.getAgentById({ agent_id: agentId });
- return { found: true, agent };
- } catch (error) {
- if (error && typeof error === 'object' && 'response' in error) {
- const axiosError = error as { response?: { status?: number } };
- const status = axiosError.response?.status;
- if (status === 404 || status === 403) {
- return { found: false };
- }
- }
- throw error;
- }
- },
+ queryFn: (): Promise => dataService.getAgentById({ agent_id: agentId }),
staleTime: 1000 * 60 * 5,
+ retry: (failureCount: number, error: unknown) =>
+ !isMissingAgentError(error) && failureCount < 3,
})),
});
const staleAgentIdsKey = useMemo(() => {
+ // Only persist cleanup once the global map has loaded. A revoked AGENTS.USE role
+ // makes every getAgentById return a global 403, which must not delete favorites.
+ if (agentsMap === undefined) {
+ return '';
+ }
const ids: string[] = [];
- for (let i = 0; i < missingAgentIds.length; i++) {
- const query = missingAgentQueries[i];
- if (query.data && !query.data.found) {
- ids.push(missingAgentIds[i]);
+ for (let i = 0; i < agentIdsToFetch.length; i++) {
+ const query = agentQueries[i];
+ if (query.isError && isMissingAgentError(query.error)) {
+ ids.push(agentIdsToFetch[i]);
}
}
return ids.sort().join(',');
- }, [missingAgentIds, missingAgentQueries]);
+ }, [agentIdsToFetch, agentQueries, agentsMap]);
const cleanupAttemptedRef = useRef('');
@@ -293,26 +303,29 @@ export default function FavoritesList({
}, [staleSpecNamesKey, safeFavorites, reorderFavorites]);
const combinedAgentsMap = useMemo(() => {
- if (agentsMap === undefined) {
- return undefined;
- }
const combined: Record = {};
- for (const [key, value] of Object.entries(agentsMap)) {
- if (value) {
- combined[key] = value;
+ if (agentsMap) {
+ for (const [key, value] of Object.entries(agentsMap)) {
+ if (value) {
+ combined[key] = value;
+ }
}
}
- missingAgentQueries.forEach((query) => {
- if (query.data?.found) {
- combined[query.data.agent.id] = query.data.agent;
+ agentQueries.forEach((query) => {
+ if (query.data) {
+ combined[query.data.id] = query.data;
}
});
return combined;
- }, [agentsMap, missingAgentQueries]);
+ }, [agentsMap, agentQueries]);
const isAgentsLoading =
- (allAgentIds.length > 0 && agentsMap === undefined) ||
- (missingAgentIds.length > 0 && missingAgentQueries.some((q) => q.isLoading));
+ allAgentIds.length > 0 &&
+ (isEndpointsLoading ||
+ agentQueries.some(
+ (q) =>
+ q.isLoading || (agentsMap === undefined && q.isError && !isMissingAgentError(q.error)),
+ ));
const draggedFavoritesRef = useRef(safeFavorites);
diff --git a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx
index e1fd91590f..7e61692d51 100644
--- a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx
+++ b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx
@@ -4,8 +4,8 @@ import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { BrowserRouter } from 'react-router-dom';
-import { dataService } from 'librechat-data-provider';
import { HTML5Backend } from 'react-dnd-html5-backend';
+import { dataService, EModelEndpoint } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Agent } from 'librechat-data-provider';
@@ -60,9 +60,10 @@ jest.mock('~/hooks', () => ({
useGetConversation: () => () => null,
}));
+const mockUseAgentsMapContext = jest.fn((): Record | undefined => ({}));
jest.mock('~/Providers', () => ({
useAssistantsMapContext: () => ({}),
- useAgentsMapContext: () => ({}),
+ useAgentsMapContext: () => mockUseAgentsMapContext(),
}));
const mockOnSelectSpec = jest.fn();
@@ -75,8 +76,14 @@ const mockUseGetStartupConfig = jest.fn((): { data?: { modelSpecs: { list: unkno
data: { modelSpecs: { list: [] as unknown[] } },
}));
+const mockUseGetEndpointsQuery = jest.fn(
+ (): { data?: Record; isLoading?: boolean } => ({
+ data: { [EModelEndpoint.agents]: { order: 0 } },
+ }),
+);
+
jest.mock('~/data-provider', () => ({
- useGetEndpointsQuery: () => ({ data: {} }),
+ useGetEndpointsQuery: () => mockUseGetEndpointsQuery(),
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
@@ -123,6 +130,10 @@ describe('FavoritesList', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFavorites.length = 0;
+ mockUseAgentsMapContext.mockImplementation(() => ({}));
+ mockUseGetEndpointsQuery.mockImplementation(() => ({
+ data: { [EModelEndpoint.agents]: { order: 0 } },
+ }));
mockUseFavorites.mockImplementation(() => ({
favorites: mockFavorites,
reorderFavorites: jest.fn(),
@@ -213,6 +224,102 @@ describe('FavoritesList', () => {
expect(queryAllByTestId('favorite-item')).toHaveLength(0);
});
+ it('renders pinned agents via direct fetch while the global agents map is still loading', async () => {
+ const validAgent: Agent = {
+ id: 'pinned-agent',
+ name: 'Pinned Agent',
+ author: 'test-author',
+ } as Agent;
+
+ mockUseAgentsMapContext.mockImplementation(() => undefined);
+ mockFavorites.push({ agentId: 'pinned-agent' });
+
+ (dataService.getAgentById as jest.Mock).mockResolvedValue(validAgent);
+
+ const { findAllByTestId } = renderWithProviders();
+
+ const favoriteItems = await findAllByTestId('favorite-item');
+ expect(favoriteItems).toHaveLength(1);
+ expect(favoriteItems[0]).toHaveTextContent('Pinned Agent');
+ expect(dataService.getAgentById as jest.Mock).toHaveBeenCalledWith({
+ agent_id: 'pinned-agent',
+ });
+ });
+
+ it('does not fetch or render agents when the agents endpoint is disabled', async () => {
+ mockUseGetEndpointsQuery.mockImplementation(() => ({ data: {} }));
+ mockUseAgentsMapContext.mockImplementation(() => undefined);
+ mockFavorites.push({ agentId: 'pinned-agent' });
+
+ const { queryAllByTestId } = renderWithProviders();
+
+ await new Promise((r) => setTimeout(r, 50));
+ expect(dataService.getAgentById as jest.Mock).not.toHaveBeenCalled();
+ expect(queryAllByTestId('favorite-item')).toHaveLength(0);
+ });
+
+ it('keeps skeletons (not an empty row) while the endpoints config is still loading', async () => {
+ mockUseGetEndpointsQuery.mockImplementation(() => ({ data: undefined, isLoading: true }));
+ mockUseAgentsMapContext.mockImplementation(() => undefined);
+ mockFavorites.push({ agentId: 'pinned-agent' });
+
+ const { queryAllByTestId } = renderWithProviders();
+
+ await new Promise((r) => setTimeout(r, 50));
+ // Endpoint gate is undecided, so no direct fetch yet, but the row stays as a skeleton.
+ expect(dataService.getAgentById as jest.Mock).not.toHaveBeenCalled();
+ expect(queryAllByTestId('favorite-item-skeleton').length).toBeGreaterThan(0);
+ expect(queryAllByTestId('favorite-item')).toHaveLength(0);
+ });
+
+ it('does not delete favorites on a global 403 while the agents map is unavailable', async () => {
+ const mockReorderFavorites = jest.fn();
+ // A revoked AGENTS.USE role makes the list query (and thus the map) fail, so the
+ // map stays undefined and every getAgentById returns a global 403.
+ mockUseAgentsMapContext.mockImplementation(() => undefined);
+ mockUseFavorites.mockImplementation(() => ({
+ favorites: [{ agentId: 'agent-a' }, { agentId: 'agent-b' }],
+ reorderFavorites: mockReorderFavorites,
+ isLoading: false,
+ }));
+
+ (dataService.getAgentById as jest.Mock).mockRejectedValue({ response: { status: 403 } });
+
+ const { queryAllByTestId } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(dataService.getAgentById as jest.Mock).toHaveBeenCalledWith({ agent_id: 'agent-a' });
+ });
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(mockReorderFavorites).not.toHaveBeenCalled();
+ expect(queryAllByTestId('favorite-item')).toHaveLength(0);
+ });
+
+ it('keeps a pinned agent as a skeleton on a transient error while the map is still loading', async () => {
+ const mockReorderFavorites = jest.fn();
+ mockUseAgentsMapContext.mockImplementation(() => undefined);
+ mockUseFavorites.mockImplementation(() => ({
+ favorites: [{ agentId: 'flaky-agent' }],
+ reorderFavorites: mockReorderFavorites,
+ isLoading: false,
+ }));
+
+ (dataService.getAgentById as jest.Mock).mockRejectedValue({ response: { status: 500 } });
+
+ const { queryAllByTestId } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(dataService.getAgentById as jest.Mock).toHaveBeenCalledWith({
+ agent_id: 'flaky-agent',
+ });
+ });
+
+ // Item is neither rendered nor cleaned up — it stays in the loading skeleton state.
+ expect(queryAllByTestId('favorite-item')).toHaveLength(0);
+ expect(mockReorderFavorites).not.toHaveBeenCalled();
+ });
+
it('should treat 403 the same as 404 — agent not rendered', async () => {
const validAgent: Agent = {
id: 'valid-agent',