From c7531dd0290e77ab60ca371db8050f5ea8ada116 Mon Sep 17 00:00:00 2001 From: ethanlaj Date: Wed, 11 Feb 2026 22:12:05 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=B5=EF=B8=8F=E2=80=8D=E2=99=82?= =?UTF-8?q?=EF=B8=8F=20fix:=20Handle=20404=20errors=20on=20agent=20queries?= =?UTF-8?q?=20for=20favorites=20(#11587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/common/agents-types.ts | 2 + .../Nav/Favorites/FavoritesList.tsx | 20 +- .../Favorites/tests/FavoritesList.spec.tsx | 191 ++++++++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index c3832b7ff8..c3ea06f890 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -9,6 +9,8 @@ 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 b142b0cfc3..86fe4a793f 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -9,6 +9,7 @@ import { QueryKeys, dataService } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import { useFavorites, useLocalize, useShowMarketplace, useNewConvo } from '~/hooks'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; +import type { AgentQueryResult } from '~/common'; import useSelectMention from '~/hooks/Input/useSelectMention'; import { useGetEndpointsQuery } from '~/data-provider'; import FavoriteItem from './FavoriteItem'; @@ -184,7 +185,20 @@ export default function FavoritesList({ const missingAgentQueries = useQueries({ queries: missingAgentIds.map((agentId) => ({ queryKey: [QueryKeys.agent, agentId], - queryFn: () => dataService.getAgentById({ agent_id: 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 } }; + if (axiosError.response?.status === 404) { + return { found: false }; + } + } + throw error; + } + }, staleTime: 1000 * 60 * 5, enabled: missingAgentIds.length > 0, })), @@ -201,8 +215,8 @@ export default function FavoritesList({ } } missingAgentQueries.forEach((query) => { - if (query.data) { - combined[query.data.id] = query.data; + if (query.data?.found) { + combined[query.data.agent.id] = query.data.agent; } }); return combined; diff --git a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx new file mode 100644 index 0000000000..8318b94698 --- /dev/null +++ b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RecoilRoot } from 'recoil'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { BrowserRouter } from 'react-router-dom'; +import { dataService } from 'librechat-data-provider'; +import type t from 'librechat-data-provider'; + +// Mock store before importing FavoritesList +jest.mock('~/store', () => { + const { atom } = jest.requireActual('recoil'); + return { + __esModule: true, + default: { + search: atom({ + key: 'mock-search-atom', + default: { query: '' }, + }), + conversationByIndex: (index: number) => + atom({ + key: `mock-conversation-atom-${index}`, + default: null, + }), + }, + }; +}); +import FavoritesList from '../FavoritesList'; + +type FavoriteItem = { + agentId?: string; + model?: string; + endpoint?: string; +}; + +// Mock dataService +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + dataService: { + getAgentById: jest.fn(), + }, +})); + +// Mock hooks +const mockFavorites: FavoriteItem[] = []; +const mockUseFavorites = jest.fn(() => ({ + favorites: mockFavorites, + reorderFavorites: jest.fn(), + isLoading: false, +})); + +jest.mock('~/hooks', () => ({ + useFavorites: () => mockUseFavorites(), + useLocalize: () => (key: string) => key, + useShowMarketplace: () => false, + useNewConvo: () => ({ newConversation: jest.fn() }), +})); + +jest.mock('~/Providers', () => ({ + useAssistantsMapContext: () => ({}), + useAgentsMapContext: () => ({}), +})); + +jest.mock('~/hooks/Input/useSelectMention', () => () => ({ + onSelectEndpoint: jest.fn(), +})); + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: {} }), +})); + +jest.mock('../FavoriteItem', () => ({ + __esModule: true, + default: ({ item, type }: { item: any; type: string }) => ( +
+ {type === 'agent' ? item.name : item.model} +
+ ), +})); + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const renderWithProviders = (ui: React.ReactElement) => { + const queryClient = createTestQueryClient(); + return render( + + + + {ui} + + + , + ); +}; + +describe('FavoritesList', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFavorites.length = 0; + }); + + describe('rendering', () => { + it('should render nothing when favorites is empty and marketplace is hidden', () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + + it('should render skeleton while loading', () => { + mockUseFavorites.mockReturnValueOnce({ + favorites: [], + reorderFavorites: jest.fn(), + isLoading: true, + }); + + const { container } = renderWithProviders(); + // Skeletons should be present during loading - container should have children + expect(container.firstChild).not.toBeNull(); + // When loading, the component renders skeleton placeholders (check for content, not specific CSS) + expect(container.innerHTML).toContain('div'); + }); + }); + + describe('missing agent handling', () => { + it('should exclude missing agents (404) from rendered favorites and render valid agents', async () => { + const validAgent: t.Agent = { + id: 'valid-agent', + name: 'Valid Agent', + author: 'test-author', + } as t.Agent; + + // Set up favorites with both valid and missing agent + mockFavorites.push({ agentId: 'valid-agent' }, { agentId: 'deleted-agent' }); + + // Mock getAgentById: valid-agent returns successfully, deleted-agent returns 404 + (dataService.getAgentById as jest.Mock).mockImplementation( + ({ agent_id }: { agent_id: string }) => { + if (agent_id === 'valid-agent') { + return Promise.resolve(validAgent); + } + if (agent_id === 'deleted-agent') { + return Promise.reject({ response: { status: 404 } }); + } + return Promise.reject(new Error('Unknown agent')); + }, + ); + + const { findAllByTestId } = renderWithProviders(); + + // Wait for queries to resolve + const favoriteItems = await findAllByTestId('favorite-item'); + + // Only the valid agent should be rendered + expect(favoriteItems).toHaveLength(1); + expect(favoriteItems[0]).toHaveTextContent('Valid Agent'); + + // The deleted agent should still be requested, but not rendered + expect(dataService.getAgentById as jest.Mock).toHaveBeenCalledWith({ + agent_id: 'deleted-agent', + }); + }); + + it('should not show infinite loading skeleton when agents return 404', async () => { + // Set up favorites with only a deleted agent + mockFavorites.push({ agentId: 'deleted-agent' }); + + // Mock getAgentById to return 404 + (dataService.getAgentById as jest.Mock).mockRejectedValue({ response: { status: 404 } }); + + const { queryAllByTestId } = renderWithProviders(); + + // Wait for the loading state to resolve after 404 handling by ensuring the agent request was made + await waitFor(() => { + expect(dataService.getAgentById as jest.Mock).toHaveBeenCalledWith({ + agent_id: 'deleted-agent', + }); + }); + + // No favorite items should be rendered (deleted agent is filtered out) + expect(queryAllByTestId('favorite-item')).toHaveLength(0); + }); + }); +});