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',