From cde5079886b15d67f37f6ad4bbefbb7197ce818a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Feb 2026 15:01:51 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AF=20fix:=20Use=20Agents=20Endpoint?= =?UTF-8?q?=20Config=20for=20Agent=20Panel=20File=20Upload=20Validation=20?= =?UTF-8?q?(#11992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Use correct endpoint for file validation in agent panel uploads Agent panel file uploads (FileSearch, FileContext, Code/Files) were validating against the active conversation's endpoint config instead of the agents endpoint config. This caused incorrect file size limits when the active chat used a different endpoint. Add endpointOverride option to useFileHandling so callers can specify the correct endpoint for validation independent of the active conversation. * fix: Use agents endpoint config for agent panel file upload validation Agent panel file uploads (FileSearch, FileContext, Code/Files) validated against the active conversation's endpoint config instead of the agents endpoint config. This caused wrong file size limits when the active chat used a different endpoint. Adds endpointOverride to useFileHandling so callers can specify the correct endpoint for both validation and upload routing, independent of the active conversation. * test: Add unit tests for useFileHandling hook to validate endpoint overrides Introduced comprehensive tests for the useFileHandling hook, ensuring correct behavior when using endpoint overrides for file validation and upload routing. The tests cover various scenarios, including fallback to conversation endpoints and proper handling of agent-specific configurations, enhancing the reliability of file handling in the application. --- .../SidePanel/Agents/Code/Files.tsx | 1 + .../SidePanel/Agents/FileContext.tsx | 2 + .../SidePanel/Agents/FileSearch.tsx | 2 + .../Files/__tests__/useFileHandling.test.ts | 285 ++++++++++++++++++ client/src/hooks/Files/useFileHandling.ts | 15 +- .../hooks/Files/useSharePointFileHandling.ts | 2 + 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 client/src/hooks/Files/__tests__/useFileHandling.test.ts diff --git a/client/src/components/SidePanel/Agents/Code/Files.tsx b/client/src/components/SidePanel/Agents/Code/Files.tsx index 3ef7da9ca6..88fe710334 100644 --- a/client/src/components/SidePanel/Agents/Code/Files.tsx +++ b/client/src/components/SidePanel/Agents/Code/Files.tsx @@ -35,6 +35,7 @@ export default function Files({ const { abortUpload, handleFileChange } = useFileHandling({ fileSetter: setFiles, additionalMetadata: { agent_id, tool_resource }, + endpointOverride: EModelEndpoint.agents, }); useLazyEffect( diff --git a/client/src/components/SidePanel/Agents/FileContext.tsx b/client/src/components/SidePanel/Agents/FileContext.tsx index d437e8457f..bad2c9bdee 100644 --- a/client/src/components/SidePanel/Agents/FileContext.tsx +++ b/client/src/components/SidePanel/Agents/FileContext.tsx @@ -47,10 +47,12 @@ export default function FileContext({ const { handleFileChange } = useFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.context }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); useLazyEffect( diff --git a/client/src/components/SidePanel/Agents/FileSearch.tsx b/client/src/components/SidePanel/Agents/FileSearch.tsx index a82fc8bdfb..6b3e813ef1 100644 --- a/client/src/components/SidePanel/Agents/FileSearch.tsx +++ b/client/src/components/SidePanel/Agents/FileSearch.tsx @@ -44,11 +44,13 @@ export default function FileSearch({ const { handleFileChange } = useFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); diff --git a/client/src/hooks/Files/__tests__/useFileHandling.test.ts b/client/src/hooks/Files/__tests__/useFileHandling.test.ts new file mode 100644 index 0000000000..297b0bd94d --- /dev/null +++ b/client/src/hooks/Files/__tests__/useFileHandling.test.ts @@ -0,0 +1,285 @@ +import { renderHook, act } from '@testing-library/react'; +import { Constants, EModelEndpoint, getEndpointFileConfig } from 'librechat-data-provider'; + +beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + global.URL.revokeObjectURL = jest.fn(); +}); + +const mockShowToast = jest.fn(); +const mockSetFilesLoading = jest.fn(); +const mockMutate = jest.fn(); + +let mockConversation: Record = {}; + +jest.mock('~/Providers/ChatContext', () => ({ + useChatContext: jest.fn(() => ({ + files: new Map(), + setFiles: jest.fn(), + setFilesLoading: mockSetFilesLoading, + conversation: mockConversation, + })), +})); + +jest.mock('@librechat/client', () => ({ + useToastContext: jest.fn(() => ({ + showToast: mockShowToast, + })), +})); + +jest.mock('recoil', () => ({ + ...jest.requireActual('recoil'), + useSetRecoilState: jest.fn(() => jest.fn()), +})); + +jest.mock('~/store', () => ({ + ephemeralAgentByConvoId: jest.fn(() => ({ key: 'mock' })), +})); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: jest.fn(() => ({ + getQueryData: jest.fn(), + refetchQueries: jest.fn(), + })), +})); + +jest.mock('~/data-provider', () => ({ + useGetFileConfig: jest.fn(() => ({ data: null })), + useUploadFileMutation: jest.fn((_opts: Record) => ({ + mutate: mockMutate, + })), +})); + +jest.mock('~/hooks/useLocalize', () => { + const fn = jest.fn((key: string) => key); + fn.TranslationKeys = {}; + return { __esModule: true, default: fn, TranslationKeys: {} }; +}); + +jest.mock('../useDelayedUploadToast', () => ({ + useDelayedUploadToast: jest.fn(() => ({ + startUploadTimer: jest.fn(), + clearUploadTimer: jest.fn(), + })), +})); + +jest.mock('~/utils/heicConverter', () => ({ + processFileForUpload: jest.fn(async (file: File) => file), +})); + +jest.mock('../useClientResize', () => ({ + __esModule: true, + default: jest.fn(() => ({ + resizeImageIfNeeded: jest.fn(async (file: File) => ({ file, resized: false })), + })), +})); + +jest.mock('../useUpdateFiles', () => ({ + __esModule: true, + default: jest.fn(() => ({ + addFile: jest.fn(), + replaceFile: jest.fn(), + updateFileById: jest.fn(), + deleteFileById: jest.fn(), + })), +})); + +jest.mock('~/utils', () => ({ + logger: { log: jest.fn() }, + validateFiles: jest.fn(() => true), +})); + +const mockValidateFiles = jest.requireMock('~/utils').validateFiles; + +describe('useFileHandling', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockConversation = {}; + }); + + const loadHook = async () => (await import('../useFileHandling')).default; + + describe('endpointOverride', () => { + it('uses conversation endpoint when no override is provided', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockValidateFiles).toHaveBeenCalledTimes(1); + const validateCall = mockValidateFiles.mock.calls[0][0]; + const configResult = getEndpointFileConfig({ + endpoint: 'openAI', + endpointType: 'custom', + fileConfig: null, + }); + expect(validateCall.endpointFileConfig).toEqual(configResult); + }); + + it('uses endpointOverride for validation instead of conversation endpoint', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ endpointOverride: EModelEndpoint.agents }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockValidateFiles).toHaveBeenCalledTimes(1); + const validateCall = mockValidateFiles.mock.calls[0][0]; + const agentsConfig = getEndpointFileConfig({ + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.agents, + fileConfig: null, + }); + expect(validateCall.endpointFileConfig).toEqual(agentsConfig); + }); + + it('falls back to conversation endpoint when endpointOverride is undefined', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'anthropic', + endpointType: undefined, + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling({ endpointOverride: undefined })); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockValidateFiles).toHaveBeenCalledTimes(1); + const validateCall = mockValidateFiles.mock.calls[0][0]; + const anthropicConfig = getEndpointFileConfig({ + endpoint: 'anthropic', + endpointType: undefined, + fileConfig: null, + }); + expect(validateCall.endpointFileConfig).toEqual(anthropicConfig); + }); + + it('sends correct endpoint in upload form data when override is set', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ + endpointOverride: EModelEndpoint.agents, + additionalMetadata: { agent_id: 'agent-123' }, + }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe(EModelEndpoint.agents); + expect(formData.get('endpointType')).toBe(EModelEndpoint.agents); + }); + + it('does not enter assistants upload path when override is agents', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'assistants', + endpointType: 'assistants', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ + endpointOverride: EModelEndpoint.agents, + additionalMetadata: { agent_id: 'agent-123' }, + }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe(EModelEndpoint.agents); + expect(formData.get('message_file')).toBeNull(); + expect(formData.get('version')).toBeNull(); + expect(formData.get('model')).toBeNull(); + expect(formData.get('assistant_id')).toBeNull(); + }); + + it('enters assistants path without override when conversation is assistants', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'assistants', + endpointType: 'assistants', + assistant_id: 'asst-456', + model: 'gpt-4', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe('assistants'); + expect(formData.get('message_file')).toBe('true'); + }); + + it('falls back to "default" when no conversation endpoint and no override', async () => { + mockConversation = { + conversationId: Constants.NEW_CONVO, + endpoint: null, + endpointType: undefined, + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe('default'); + }); + }); +}); diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 4c65b80765..2d37dfd654 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -13,7 +13,7 @@ import { defaultAssistantsVersion, } from 'librechat-data-provider'; import debounce from 'lodash/debounce'; -import type { TEndpointsConfig, TError } from 'librechat-data-provider'; +import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; @@ -29,6 +29,8 @@ type UseFileHandling = { fileSetter?: FileSetter; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; + /** Overrides both `endpoint` and `endpointType` for validation and upload routing */ + endpointOverride?: EModelEndpoint; }; const useFileHandling = (params?: UseFileHandling) => { @@ -50,8 +52,15 @@ const useFileHandling = (params?: UseFileHandling) => { const agent_id = params?.additionalMetadata?.agent_id ?? ''; const assistant_id = params?.additionalMetadata?.assistant_id ?? ''; - const endpointType = useMemo(() => conversation?.endpointType, [conversation?.endpointType]); - const endpoint = useMemo(() => conversation?.endpoint ?? 'default', [conversation?.endpoint]); + const endpointOverride = params?.endpointOverride; + const endpointType = useMemo( + () => endpointOverride ?? conversation?.endpointType, + [endpointOverride, conversation?.endpointType], + ); + const endpoint = useMemo( + () => endpointOverride ?? conversation?.endpoint ?? 'default', + [endpointOverride, conversation?.endpoint], + ); const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), diff --git a/client/src/hooks/Files/useSharePointFileHandling.ts b/client/src/hooks/Files/useSharePointFileHandling.ts index 11fc0915b7..82ff7b555b 100644 --- a/client/src/hooks/Files/useSharePointFileHandling.ts +++ b/client/src/hooks/Files/useSharePointFileHandling.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import useFileHandling from './useFileHandling'; import useSharePointDownload from './useSharePointDownload'; +import type { EModelEndpoint } from 'librechat-data-provider'; import type { SharePointFile } from '~/data-provider/Files/sharepoint'; interface UseSharePointFileHandlingProps { @@ -8,6 +9,7 @@ interface UseSharePointFileHandlingProps { toolResource?: string; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; + endpointOverride?: EModelEndpoint; } interface UseSharePointFileHandlingReturn {