fix: Resolve Agent Provider Endpoint Type for File Upload Support (#12117)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* chore: Remove unused setValueOnChange prop from MCPServerMenuItem component

* fix: Resolve agent provider endpoint type for file upload support

When using the agents endpoint with a custom provider (e.g., Moonshot),
the endpointType was resolving to "agents" instead of the provider's
actual type ("custom"), causing "Upload to Provider" to not appear in
the file attach menu.

Adds `resolveEndpointType` utility in data-provider that follows the
chain: endpoint (if not agents) → agent.provider → agents. Applied
consistently across AttachFileChat, DragDropContext, useDragHelpers,
and AgentPanel file components (FileContext, FileSearch, Code/Files).

* refactor: Extract useAgentFileConfig hook, restore deleted tests, fix review findings

- Extract shared provider resolution logic into useAgentFileConfig hook
  (Finding #2: DRY violation across FileContext, FileSearch, Code/Files)
- Restore 18 deleted test cases in AttachFileMenu.spec.tsx covering
  agent capabilities, SharePoint, edge cases, and button state
  (Finding #1: accidental test deletion)
- Wrap fileConfigEndpoint in useMemo in AttachFileChat (Finding #3)
- Fix misleading test name in AgentFileConfig.spec.tsx (Finding #4)
- Fix import order in FileSearch.tsx, FileContext.tsx, Code/Files.tsx (Finding #5)
- Add comment about cache gap in useDragHelpers (Finding #6)
- Clarify resolveEndpointType JSDoc (Finding #7)

* refactor: Memoize Footer component for performance optimization

- Converted Footer component to a memoized version to prevent unnecessary re-renders.
- Improved import structure by adding memo to the React import statement for clarity.

* chore: Fix remaining review nits

- Widen useAgentFileConfig return type to EModelEndpoint | string
- Fix import order in FileContext.tsx and FileSearch.tsx
- Remove dead endpointType param from setupMocks in AttachFileMenu test

* fix: Pass resolved provider endpoint to file upload validation

AgentPanel file components (FileContext, FileSearch, Code/Files) were
hardcoding endpointOverride to "agents", causing both client-side
validation (file limits, MIME types) and server-side validation to
use the agents config instead of the provider-specific config.

Adds endpointTypeOverride to UseFileHandling params so endpoint and
endpointType can be set independently. Components now pass the
resolved provider name and type from useAgentFileConfig, so the full
fallback chain (provider → custom → agents → default) applies to
file upload validation on both client and server.

* test: Verify any custom endpoint is document-supported regardless of name

Adds parameterized tests with arbitrary endpoint names (spaces, hyphens,
colons, etc.) confirming that all custom endpoints resolve to
document-supported through resolveEndpointType, both as direct
endpoints and as agent providers.

* fix: Use || for provider fallback, test endpointOverride wiring

- Change providerValue ?? to providerValue || so empty string is
  treated as "no provider" consistently with resolveEndpointType
- Add wiring tests to CodeFiles, FileContext, FileSearch verifying
  endpointOverride and endpointTypeOverride are passed correctly
- Update endpointOverride JSDoc to document endpointType fallback
This commit is contained in:
Danny Avila 2026-03-07 10:45:43 -05:00 committed by GitHub
parent cfaa6337c1
commit 2ac62a2e71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1573 additions and 582 deletions

View file

@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider';
import { isAgentsEndpoint, resolveEndpointType } from 'librechat-data-provider';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from './AgentsMapContext';
@ -9,7 +9,7 @@ interface DragDropContextValue {
conversationId: string | null | undefined;
agentId: string | null | undefined;
endpoint: string | null | undefined;
endpointType?: EModelEndpoint | undefined;
endpointType?: EModelEndpoint | string | undefined;
useResponsesApi?: boolean;
}
@ -20,13 +20,6 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
const { data: endpointsConfig } = useGetEndpointsQuery();
const agentsMap = useAgentsMapContext();
const endpointType = useMemo(() => {
return (
getEndpointField(endpointsConfig, conversation?.endpoint, 'type') ||
(conversation?.endpoint as EModelEndpoint | undefined)
);
}, [conversation?.endpoint, endpointsConfig]);
const needsAgentFetch = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id) {
@ -40,6 +33,20 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
enabled: needsAgentFetch,
});
const agentProvider = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id) {
return undefined;
}
const agent = agentData || agentsMap?.[conversation.agent_id];
return agent?.provider;
}, [conversation?.endpoint, conversation?.agent_id, agentData, agentsMap]);
const endpointType = useMemo(
() => resolveEndpointType(endpointsConfig, conversation?.endpoint, agentProvider),
[endpointsConfig, conversation?.endpoint, agentProvider],
);
const useResponsesApi = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {

View file

@ -0,0 +1,134 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
import { DragDropProvider, useDragDropContext } from '../DragDropContext';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
[EModelEndpoint.anthropic]: { userProvide: false, order: 6 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
let mockConversation: Record<string, unknown> | null = null;
let mockAgentsMap: Record<string, Partial<Agent>> = {};
let mockAgentQueryData: Partial<Agent> | undefined;
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
}));
jest.mock('../AgentsMapContext', () => ({
useAgentsMapContext: () => mockAgentsMap,
}));
jest.mock('../ChatContext', () => ({
useChatContext: () => ({ conversation: mockConversation }),
}));
function wrapper({ children }: { children: React.ReactNode }) {
return <DragDropProvider>{children}</DragDropProvider>;
}
describe('DragDropContext endpointType resolution', () => {
beforeEach(() => {
mockConversation = null;
mockAgentsMap = {};
mockAgentQueryData = undefined;
});
describe('non-agents endpoints', () => {
it('resolves custom endpoint type for a custom endpoint', () => {
mockConversation = { endpoint: 'Moonshot' };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
it('resolves endpoint name for a standard endpoint', () => {
mockConversation = { endpoint: EModelEndpoint.openAI };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
});
});
describe('agents endpoint with provider from agentsMap', () => {
it('resolves to custom for agent with Moonshot provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
it('resolves to custom for agent with custom provider with spaces', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: 'Some Endpoint', model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
it('resolves to openAI for agent with openAI provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
});
it('resolves to anthropic for agent with anthropic provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.anthropic, model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.anthropic);
});
});
describe('agents endpoint with provider from agentData query', () => {
it('uses agentData when agent is not in agentsMap', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-2' };
mockAgentsMap = {};
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
});
describe('agents endpoint without provider', () => {
it('falls back to agents when no agent_id', () => {
mockConversation = { endpoint: EModelEndpoint.agents };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
});
it('falls back to agents when agent has no provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = { 'agent-1': { model_parameters: {} } as Partial<Agent> };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
});
});
describe('consistency: same endpoint type whether used directly or through agents', () => {
it('Moonshot resolves to the same type as direct endpoint and as agent provider', () => {
mockConversation = { endpoint: 'Moonshot' };
const { result: directResult } = renderHook(() => useDragDropContext(), { wrapper });
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
const { result: agentResult } = renderHook(() => useDragDropContext(), { wrapper });
expect(directResult.current.endpointType).toBe(agentResult.current.endpointType);
});
});
});

View file

@ -1,11 +1,11 @@
import React, { useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import React, { useEffect, memo } from 'react';
import TagManager from 'react-gtm-module';
import ReactMarkdown from 'react-markdown';
import { Constants } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
export default function Footer({ className }: { className?: string }) {
function Footer({ className }: { className?: string }) {
const { data: config } = useGetStartupConfig();
const localize = useLocalize();
@ -98,3 +98,8 @@ export default function Footer({ className }: { className?: string }) {
</div>
);
}
const MemoizedFooter = memo(Footer);
MemoizedFooter.displayName = 'Footer';
export default MemoizedFooter;

View file

@ -2,10 +2,9 @@ import { memo, useMemo } from 'react';
import {
Constants,
supportsFiles,
EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
getEndpointField,
resolveEndpointType,
isAssistantsEndpoint,
getEndpointFileConfig,
} from 'librechat-data-provider';
@ -55,21 +54,31 @@ function AttachFileChat({
const { data: endpointsConfig } = useGetEndpointsQuery();
const endpointType = useMemo(() => {
return (
getEndpointField(endpointsConfig, endpoint, 'type') ||
(endpoint as EModelEndpoint | undefined)
);
}, [endpoint, endpointsConfig]);
const agentProvider = useMemo(() => {
if (!isAgents || !conversation?.agent_id) {
return undefined;
}
const agent = agentData || agentsMap?.[conversation.agent_id];
return agent?.provider;
}, [isAgents, conversation?.agent_id, agentData, agentsMap]);
const endpointType = useMemo(
() => resolveEndpointType(endpointsConfig, endpoint, agentProvider),
[endpointsConfig, endpoint, agentProvider],
);
const fileConfigEndpoint = useMemo(
() => (isAgents && agentProvider ? agentProvider : endpoint),
[isAgents, agentProvider, endpoint],
);
const endpointFileConfig = useMemo(
() =>
getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
endpoint: fileConfigEndpoint,
}),
[endpoint, fileConfig, endpointType],
[fileConfigEndpoint, fileConfig, endpointType],
);
const endpointSupportsFiles: boolean = useMemo(
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,

View file

@ -50,7 +50,7 @@ interface AttachFileMenuProps {
endpoint?: string | null;
disabled?: boolean | null;
conversationId: string;
endpointType?: EModelEndpoint;
endpointType?: EModelEndpoint | string;
endpointFileConfig?: EndpointFileConfig;
useResponsesApi?: boolean;
}

View file

@ -0,0 +1,176 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider';
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
import AttachFileChat from '../AttachFileChat';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
[EModelEndpoint.assistants]: { userProvide: false, order: 2 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
const mockFileConfig = mergeFileConfig({
endpoints: {
Moonshot: { fileLimit: 5 },
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
let mockAgentsMap: Record<string, Partial<Agent>> = {};
let mockAgentQueryData: Partial<Agent> | undefined;
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({
data: select != null ? select(mockFileConfig) : mockFileConfig,
}),
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
}));
jest.mock('~/Providers', () => ({
useAgentsMapContext: () => mockAgentsMap,
}));
/** Capture the props passed to AttachFileMenu */
let mockAttachFileMenuProps: Record<string, unknown> = {};
jest.mock('../AttachFileMenu', () => {
return function MockAttachFileMenu(props: Record<string, unknown>) {
mockAttachFileMenuProps = props;
return <div data-testid="attach-file-menu" data-endpoint-type={String(props.endpointType)} />;
};
});
jest.mock('../AttachFile', () => {
return function MockAttachFile() {
return <div data-testid="attach-file" />;
};
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderComponent(conversation: Record<string, unknown> | null, disableInputs = false) {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileChat conversation={conversation as never} disableInputs={disableInputs} />
</RecoilRoot>
</QueryClientProvider>,
);
}
describe('AttachFileChat', () => {
beforeEach(() => {
mockAgentsMap = {};
mockAgentQueryData = undefined;
mockAttachFileMenuProps = {};
});
describe('rendering decisions', () => {
it('renders AttachFileMenu for agents endpoint', () => {
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
});
it('renders AttachFileMenu for custom endpoint with file support', () => {
renderComponent({ endpoint: 'Moonshot' });
expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
});
it('renders null for null conversation', () => {
const { container } = renderComponent(null);
expect(container.innerHTML).toBe('');
});
});
describe('endpointType resolution for agents', () => {
it('passes custom endpointType when agent provider is a custom endpoint', () => {
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
});
it('passes openAI endpointType when agent provider is openAI', () => {
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
});
it('passes agents endpointType when no agent provider', () => {
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
});
it('passes agents endpointType when no agent_id', () => {
renderComponent({ endpoint: EModelEndpoint.agents });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
});
it('uses agentData query when agent not in agentsMap', () => {
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-2' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
});
});
describe('endpointType resolution for non-agents', () => {
it('passes custom endpointType for a custom endpoint', () => {
renderComponent({ endpoint: 'Moonshot' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
});
it('passes openAI endpointType for openAI endpoint', () => {
renderComponent({ endpoint: EModelEndpoint.openAI });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
});
});
describe('consistency: same endpoint type for direct vs agent usage', () => {
it('resolves Moonshot the same way whether used directly or through an agent', () => {
renderComponent({ endpoint: 'Moonshot' });
const directType = mockAttachFileMenuProps.endpointType;
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
const agentType = mockAttachFileMenuProps.endpointType;
expect(directType).toBe(agentType);
});
});
describe('endpointFileConfig resolution', () => {
it('passes Moonshot-specific file config for agent with Moonshot provider', () => {
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
expect(config?.fileLimit).toBe(5);
});
it('passes agents file config when agent has no specific provider config', () => {
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
expect(config?.fileLimit).toBe(10);
});
it('passes agents file config when no agent provider', () => {
renderComponent({ endpoint: EModelEndpoint.agents });
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
expect(config?.fileLimit).toBe(20);
});
});
});

View file

@ -1,12 +1,10 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, Providers } from 'librechat-data-provider';
import AttachFileMenu from '../AttachFileMenu';
// Mock all the hooks
jest.mock('~/hooks', () => ({
useAgentToolPermissions: jest.fn(),
useAgentCapabilities: jest.fn(),
@ -25,53 +23,45 @@ jest.mock('~/data-provider', () => ({
}));
jest.mock('~/components/SharePoint', () => ({
SharePointPickerDialog: jest.fn(() => null),
SharePointPickerDialog: () => null,
}));
jest.mock('@librechat/client', () => {
const React = jest.requireActual('react');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const R = require('react');
return {
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
<div data-testid="file-upload">
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" />
{children}
</div>
)),
TooltipAnchor: ({ render }: any) => render,
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
const handleTriggerClick = () => {
if (setIsOpen) {
setIsOpen(!isOpen);
}
};
return (
<div>
<div onClick={handleTriggerClick}>{trigger}</div>
{isOpen && (
<div data-testid="dropdown-menu">
{items.map((item: any, idx: number) => (
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}>
{item.label}
</button>
))}
</div>
)}
</div>
);
},
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
FileUpload: (props) => R.createElement('div', { 'data-testid': 'file-upload' }, props.children),
TooltipAnchor: (props) => props.render,
DropdownPopup: (props) =>
R.createElement(
'div',
null,
R.createElement('div', { onClick: () => props.setIsOpen(!props.isOpen) }, props.trigger),
props.isOpen &&
R.createElement(
'div',
{ 'data-testid': 'dropdown-menu' },
props.items.map((item, idx) =>
R.createElement(
'button',
{ key: idx, onClick: item.onClick, 'data-testid': `menu-item-${idx}` },
item.label,
),
),
),
),
AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }),
SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }),
};
});
jest.mock('@ariakit/react', () => ({
MenuButton: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
}));
jest.mock('@ariakit/react', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const R = require('react');
return {
MenuButton: (props) => R.createElement('button', props, props.children),
};
});
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
@ -83,558 +73,283 @@ const mockUseSharePointFileHandling = jest.requireMock(
).default;
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
describe('AttachFileMenu', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const mockHandleFileChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
mockUseLocalize.mockReturnValue((key: string) => {
const translations: Record<string, string> = {
com_ui_upload_provider: 'Upload to Provider',
com_ui_upload_image_input: 'Upload Image',
com_ui_upload_ocr_text: 'Upload OCR Text',
com_ui_upload_file_search: 'Upload for File Search',
com_ui_upload_code_files: 'Upload Code Files',
com_sidepanel_attach_files: 'Attach Files',
com_files_upload_sharepoint: 'Upload from SharePoint',
};
return translations[key] || key;
});
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
});
mockUseGetAgentsConfig.mockReturnValue({
agentsConfig: {
capabilities: {
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
},
},
});
mockUseFileHandling.mockReturnValue({
handleFileChange: mockHandleFileChange,
});
mockUseSharePointFileHandling.mockReturnValue({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
});
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: false,
},
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
});
const renderAttachFileMenu = (props: any = {}) => {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileMenu conversationId="test-conversation" {...props} />
</RecoilRoot>
</QueryClientProvider>,
);
function setupMocks(overrides: { provider?: string } = {}) {
const translations: Record<string, string> = {
com_ui_upload_provider: 'Upload to Provider',
com_ui_upload_image_input: 'Upload Image',
com_ui_upload_ocr_text: 'Upload as Text',
com_ui_upload_file_search: 'Upload for File Search',
com_ui_upload_code_files: 'Upload Code Files',
com_sidepanel_attach_files: 'Attach Files',
com_files_upload_sharepoint: 'Upload from SharePoint',
};
describe('Basic Rendering', () => {
it('should render the attachment button', () => {
renderAttachFileMenu();
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
it('should be disabled when disabled prop is true', () => {
renderAttachFileMenu({ disabled: true });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeDisabled();
});
it('should not be disabled when disabled prop is false', () => {
renderAttachFileMenu({ disabled: false });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).not.toBeDisabled();
});
mockUseLocalize.mockReturnValue((key: string) => translations[key] || key);
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
});
mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} });
mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() });
mockUseSharePointFileHandling.mockReturnValue({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
});
mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } });
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: overrides.provider ?? undefined,
});
}
describe('Provider Detection Fix - endpointType Priority', () => {
it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders
});
function renderMenu(props: Record<string, unknown> = {}) {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileMenu conversationId="test-convo" {...props} />
</RecoilRoot>
</QueryClientProvider>,
);
}
renderAttachFileMenu({
endpoint: 'litellm',
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
});
function openMenu() {
fireEvent.click(screen.getByRole('button', { name: /attach file options/i }));
}
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
describe('AttachFileMenu', () => {
beforeEach(jest.clearAllMocks);
// With the fix, should show "Upload to Provider" because endpointType is checked first
describe('Upload to Provider vs Upload Image', () => {
it('shows "Upload to Provider" when endpointType is custom (resolved from agent provider)', () => {
setupMocks({ provider: 'Moonshot' });
renderMenu({ endpointType: EModelEndpoint.custom });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
});
it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: 'my-custom-gateway',
});
renderAttachFileMenu({
endpoint: 'my-custom-gateway',
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
it('shows "Upload to Provider" when endpointType is openAI', () => {
setupMocks({ provider: EModelEndpoint.openAI });
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should show Upload Image when neither endpointType nor provider support documents', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: 'unsupported-provider',
});
it('shows "Upload to Provider" when endpointType is anthropic', () => {
setupMocks({ provider: EModelEndpoint.anthropic });
renderMenu({ endpointType: EModelEndpoint.anthropic });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
renderAttachFileMenu({
endpoint: 'unsupported-provider',
endpointType: 'unsupported-endpoint' as any,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
it('shows "Upload to Provider" when endpointType is google', () => {
setupMocks({ provider: Providers.GOOGLE });
renderMenu({ endpointType: EModelEndpoint.google });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('shows "Upload Image" when endpointType is agents (no provider resolution)', () => {
setupMocks();
renderMenu({ endpointType: EModelEndpoint.agents });
openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
});
it('should fallback to currentProvider when endpointType is undefined', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.openAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.openAI,
endpointType: undefined,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
it('shows "Upload Image" when neither endpointType nor provider supports documents', () => {
setupMocks({ provider: 'unknown-provider' });
renderMenu({ endpointType: 'unknown-type' });
openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
it('shows "Upload to Provider" for azureOpenAI with useResponsesApi', () => {
setupMocks({ provider: EModelEndpoint.azureOpenAI });
renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: true });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should fallback to currentProvider when endpointType is null', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.anthropic,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.anthropic,
endpointType: null,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
it('shows "Upload Image" for azureOpenAI without useResponsesApi', () => {
setupMocks({ provider: EModelEndpoint.azureOpenAI });
renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: false });
openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
});
describe('Supported Providers', () => {
const supportedProviders = [
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
{ name: 'Google', endpoint: EModelEndpoint.google },
{ name: 'Custom', endpoint: EModelEndpoint.custom },
];
supportedProviders.forEach(({ name, endpoint }) => {
it(`should show Upload to Provider for ${name}`, () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: endpoint,
});
renderAttachFileMenu({
endpoint,
endpointType: endpoint,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
describe('agent provider resolution scenario', () => {
it('shows "Upload to Provider" when agents endpoint has custom endpointType from provider', () => {
setupMocks({ provider: 'Moonshot' });
renderMenu({
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.custom,
});
});
it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.azureOpenAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.azureOpenAI,
endpointType: EModelEndpoint.azureOpenAI,
useResponsesApi: true,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.azureOpenAI,
it('shows "Upload Image" when agents endpoint has no resolved provider type', () => {
setupMocks();
renderMenu({
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.azureOpenAI,
endpointType: EModelEndpoint.azureOpenAI,
useResponsesApi: false,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
});
describe('Basic Rendering', () => {
it('renders the attachment button', () => {
setupMocks();
renderMenu();
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
it('is disabled when disabled prop is true', () => {
setupMocks();
renderMenu({ disabled: true });
expect(screen.getByRole('button', { name: /attach file options/i })).toBeDisabled();
});
it('is not disabled when disabled prop is false', () => {
setupMocks();
renderMenu({ disabled: false });
expect(screen.getByRole('button', { name: /attach file options/i })).not.toBeDisabled();
});
});
describe('Agent Capabilities', () => {
it('should show OCR Text option when context is enabled', () => {
it('shows OCR Text option when context is enabled', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true,
fileSearchEnabled: false,
codeEnabled: false,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.getByText('Upload as Text')).toBeInTheDocument();
});
it('should show File Search option when enabled and allowed by agent', () => {
it('shows File Search option when enabled and allowed by agent', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: true,
codeEnabled: false,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
});
it('should NOT show File Search when enabled but not allowed by agent', () => {
it('does NOT show File Search when enabled but not allowed by agent', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: true,
codeEnabled: false,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
});
it('should show Code Files option when enabled and allowed by agent', () => {
it('shows Code Files option when enabled and allowed by agent', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: true,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: true,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
it('should show all options when all capabilities are enabled', () => {
it('shows all options when all capabilities are enabled', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true,
fileSearchEnabled: true,
codeEnabled: true,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true,
codeAllowedByAgent: true,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
expect(screen.getByText('Upload as Text')).toBeInTheDocument();
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
});
describe('SharePoint Integration', () => {
it('should show SharePoint option when enabled', () => {
it('shows SharePoint option when enabled', () => {
setupMocks();
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: true,
},
data: { sharePointFilePickerEnabled: true },
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
});
it('should NOT show SharePoint option when disabled', () => {
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: false,
},
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
it('does NOT show SharePoint option when disabled', () => {
setupMocks();
renderMenu({ endpointType: EModelEndpoint.openAI });
openMenu();
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle undefined endpoint and provider gracefully', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpoint: undefined,
endpointType: undefined,
});
it('handles undefined endpoint and provider gracefully', () => {
setupMocks();
renderMenu({ endpoint: undefined, endpointType: undefined });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
// Should show Upload Image as fallback
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
it('should handle null endpoint and provider gracefully', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: null,
});
renderAttachFileMenu({
endpoint: null,
endpointType: null,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
it('handles null endpoint and provider gracefully', () => {
setupMocks();
renderMenu({ endpoint: null, endpointType: null });
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
it('should handle missing agentId gracefully', () => {
renderAttachFileMenu({
agentId: undefined,
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
it('handles missing agentId gracefully', () => {
setupMocks();
renderMenu({ agentId: undefined, endpointType: EModelEndpoint.openAI });
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
it('should handle empty string agentId', () => {
renderAttachFileMenu({
agentId: '',
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
});
describe('Google Provider Special Case', () => {
it('should use image_document_video_audio file type for Google provider', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.google,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.google,
endpointType: EModelEndpoint.google,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
const uploadProviderButton = screen.getByText('Upload to Provider');
expect(uploadProviderButton).toBeInTheDocument();
// Click the upload to provider option
fireEvent.click(uploadProviderButton);
// The file input should have been clicked (indirectly tested through the implementation)
});
it('should use image_document file type for non-Google providers', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.openAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.openAI,
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
const uploadProviderButton = screen.getByText('Upload to Provider');
expect(uploadProviderButton).toBeInTheDocument();
fireEvent.click(uploadProviderButton);
// Implementation detail - image_document type is used
});
});
describe('Regression Tests', () => {
it('should not break the previous behavior for direct provider attachments', () => {
// When using a direct supported provider (not through a gateway)
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.anthropic,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.anthropic,
endpointType: EModelEndpoint.anthropic,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should maintain correct priority when both are supported', () => {
// Both endpointType and provider are supported, endpointType should be checked first
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.google,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.google,
endpointType: EModelEndpoint.openAI, // Different but both supported
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
// Should still work because endpointType (openAI) is supported
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
it('handles empty string agentId', () => {
setupMocks();
renderMenu({ agentId: '', endpointType: EModelEndpoint.openAI });
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
});
});

View file

@ -46,7 +46,6 @@ export default function MCPServerMenuItem({
name="mcp-servers"
value={server.serverName}
checked={isSelected}
setValueOnChange={false}
onChange={() => onToggle(server.serverName)}
aria-label={accessibleLabel}
className={cn(

View file

@ -1,18 +1,11 @@
import { memo, useMemo, useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { AttachmentIcon } from '@librechat/client';
import {
EToolResources,
EModelEndpoint,
mergeFileConfig,
AgentCapabilities,
getEndpointFileConfig,
} from 'librechat-data-provider';
import { EToolResources, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common';
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useLocalize, useLazyEffect } from '~/hooks';
import { useGetFileConfig } from '~/data-provider';
import { isEphemeralAgent } from '~/common';
const tool_resource = EToolResources.execute_code;
@ -29,14 +22,14 @@ function Files({
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const fileHandlingState = useMemo(() => ({ files, setFiles, conversation: null }), [files]);
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { endpointFileConfig, providerValue, endpointType } = useAgentFileConfig();
const endpointOverride = providerValue || EModelEndpoint.agents;
const { abortUpload, handleFileChange } = useFileHandlingNoChatContext(
{
fileSetter: setFiles,
additionalMetadata: { agent_id, tool_resource },
endpointOverride: EModelEndpoint.agents,
endpointOverride,
endpointTypeOverride: endpointType,
},
fileHandlingState,
);
@ -52,12 +45,6 @@ function Files({
);
const codeChecked = watch(AgentCapabilities.execute_code);
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (isUploadDisabled) {

View file

@ -1,12 +1,7 @@
import { memo, useMemo, useRef, useState } from 'react';
import { Folder } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import {
EModelEndpoint,
EToolResources,
mergeFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import { EModelEndpoint, EToolResources } from 'librechat-data-provider';
import {
HoverCard,
DropdownPopup,
@ -18,13 +13,13 @@ import {
HoverCardTrigger,
} from '@librechat/client';
import type { ExtendedFile } from '~/common';
import { useLocalize, useLazyEffect } from '~/hooks';
import { useGetFileConfig, useGetStartupConfig } from '~/data-provider';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { ESide, isEphemeralAgent } from '~/common';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetStartupConfig } from '~/data-provider';
import { ESide, isEphemeralAgent } from '~/common';
function FileContext({
agent_id,
@ -41,15 +36,14 @@ function FileContext({
const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false);
const { data: startupConfig } = useGetStartupConfig();
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { endpointFileConfig, providerValue, endpointType } = useAgentFileConfig();
const endpointOverride = providerValue || EModelEndpoint.agents;
const { handleFileChange } = useFileHandlingNoChatContext(
{
additionalMetadata: { agent_id, tool_resource: EToolResources.context },
endpointOverride: EModelEndpoint.agents,
endpointOverride,
endpointTypeOverride: endpointType,
fileSetter: setFiles,
},
fileHandlingState,
@ -58,7 +52,8 @@ function FileContext({
useSharePointFileHandlingNoChatContext(
{
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
endpointOverride: EModelEndpoint.agents,
endpointOverride,
endpointTypeOverride: endpointType,
fileSetter: setFiles,
},
fileHandlingState,
@ -72,12 +67,6 @@ function FileContext({
[_files],
750,
);
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {

View file

@ -3,22 +3,16 @@ import { Folder } from 'lucide-react';
import * as Ariakit from '@ariakit/react';
import { useFormContext } from 'react-hook-form';
import { SharePointIcon, AttachmentIcon, DropdownPopup } from '@librechat/client';
import {
EModelEndpoint,
EToolResources,
mergeFileConfig,
AgentCapabilities,
getEndpointFileConfig,
} from 'librechat-data-provider';
import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common';
import { useGetFileConfig, useGetStartupConfig } from '~/data-provider';
import { useLocalize, useLazyEffect } from '~/hooks';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetStartupConfig } from '~/data-provider';
import FileSearchCheckbox from './FileSearchCheckbox';
import { isEphemeralAgent } from '~/common';
import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling';
import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling';
function FileSearch({
agent_id,
@ -37,15 +31,14 @@ function FileSearch({
// Get startup configuration for SharePoint feature flag
const { data: startupConfig } = useGetStartupConfig();
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { endpointFileConfig, providerValue, endpointType } = useAgentFileConfig();
const endpointOverride = providerValue || EModelEndpoint.agents;
const { handleFileChange } = useFileHandlingNoChatContext(
{
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
endpointOverride: EModelEndpoint.agents,
endpointOverride,
endpointTypeOverride: endpointType,
fileSetter: setFiles,
},
fileHandlingState,
@ -55,7 +48,8 @@ function FileSearch({
useSharePointFileHandlingNoChatContext(
{
additionalMetadata: { agent_id, tool_resource: EToolResources.file_search },
endpointOverride: EModelEndpoint.agents,
endpointOverride,
endpointTypeOverride: endpointType,
fileSetter: setFiles,
},
fileHandlingState,
@ -72,12 +66,6 @@ function FileSearch({
);
const fileSearchChecked = watch(AgentCapabilities.file_search);
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpoint: EModelEndpoint.agents,
endpointType: EModelEndpoint.agents,
});
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;

View file

@ -0,0 +1,151 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import { EModelEndpoint, mergeFileConfig, resolveEndpointType } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import useAgentFileConfig from '~/hooks/Agents/useAgentFileConfig';
/**
* Tests the useAgentFileConfig hook used by FileContext, FileSearch, and Code/Files.
* Uses the real hook with mocked data-fetching layer.
*/
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
let mockFileConfig = mergeFileConfig({
endpoints: {
Moonshot: { fileLimit: 5 },
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({
data: select != null ? select(mockFileConfig) : mockFileConfig,
}),
}));
function FileConfigProbe() {
const { endpointType, endpointFileConfig } = useAgentFileConfig();
return (
<div>
<span data-testid="endpointType">{String(endpointType)}</span>
<span data-testid="fileLimit">{endpointFileConfig.fileLimit}</span>
<span data-testid="disabled">{String(endpointFileConfig.disabled ?? false)}</span>
</div>
);
}
function TestWrapper({ provider }: { provider?: string | { label: string; value: string } }) {
const methods = useForm<AgentForm>({
defaultValues: { provider: provider as AgentForm['provider'] },
});
return (
<FormProvider {...methods}>
<FileConfigProbe />
</FormProvider>
);
}
describe('AgentPanel file config resolution (useAgentFileConfig)', () => {
describe('endpointType resolution from form provider', () => {
it('resolves to custom when provider is a custom endpoint string', () => {
render(<TestWrapper provider="Moonshot" />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.custom);
});
it('resolves to custom when provider is a custom endpoint with spaces', () => {
render(<TestWrapper provider="Some Endpoint" />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.custom);
});
it('resolves to openAI when provider is openAI', () => {
render(<TestWrapper provider={EModelEndpoint.openAI} />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.openAI);
});
it('falls back to agents when provider is undefined', () => {
render(<TestWrapper />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.agents);
});
it('falls back to agents when provider is empty string', () => {
render(<TestWrapper provider="" />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.agents);
expect(screen.getByTestId('fileLimit').textContent).toBe('20');
});
it('falls back to agents when provider option has empty value', () => {
render(<TestWrapper provider={{ label: '', value: '' }} />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.agents);
expect(screen.getByTestId('fileLimit').textContent).toBe('20');
});
it('resolves correctly when provider is an option object', () => {
render(<TestWrapper provider={{ label: 'Moonshot', value: 'Moonshot' }} />);
expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.custom);
});
});
describe('file config fallback chain', () => {
it('uses Moonshot-specific file config when provider is Moonshot', () => {
render(<TestWrapper provider="Moonshot" />);
expect(screen.getByTestId('fileLimit').textContent).toBe('5');
});
it('falls back to agents file config when provider has no specific config', () => {
render(<TestWrapper provider="Some Endpoint" />);
expect(screen.getByTestId('fileLimit').textContent).toBe('20');
});
it('uses agents file config when no provider is set', () => {
render(<TestWrapper />);
expect(screen.getByTestId('fileLimit').textContent).toBe('20');
});
it('falls back to default config for openAI provider (no openAI-specific config)', () => {
render(<TestWrapper provider={EModelEndpoint.openAI} />);
expect(screen.getByTestId('fileLimit').textContent).toBe('10');
});
});
describe('disabled state', () => {
it('reports not disabled for standard config', () => {
render(<TestWrapper provider="Moonshot" />);
expect(screen.getByTestId('disabled').textContent).toBe('false');
});
it('reports disabled when provider-specific config is disabled', () => {
const original = mockFileConfig;
mockFileConfig = mergeFileConfig({
endpoints: {
Moonshot: { disabled: true },
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
render(<TestWrapper provider="Moonshot" />);
expect(screen.getByTestId('disabled').textContent).toBe('true');
mockFileConfig = original;
});
});
describe('consistency with direct custom endpoint', () => {
it('resolves to the same type as a direct custom endpoint would', () => {
render(<TestWrapper provider="Moonshot" />);
const agentEndpointType = screen.getByTestId('endpointType').textContent;
const directEndpointType = resolveEndpointType(mockEndpointsConfig, 'Moonshot');
expect(agentEndpointType).toBe(directEndpointType);
});
});
});

View file

@ -0,0 +1,138 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import Files from '../Code/Files';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
let mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetFileConfig: ({ select }: { select?: (d: unknown) => unknown }) => ({
data: select != null ? select(mockFileConfig) : mockFileConfig,
}),
}));
jest.mock('~/hooks', () => ({
useAgentFileConfig: jest.requireActual('~/hooks/Agents/useAgentFileConfig').default,
useLocalize: () => (key: string) => key,
useLazyEffect: () => {},
}));
const mockUseFileHandlingNoChatContext = jest.fn().mockReturnValue({
abortUpload: jest.fn(),
handleFileChange: jest.fn(),
});
jest.mock('~/hooks/Files/useFileHandling', () => ({
useFileHandlingNoChatContext: (...args: unknown[]) => mockUseFileHandlingNoChatContext(...args),
}));
jest.mock('~/components/Chat/Input/Files/FileRow', () => () => null);
jest.mock('@librechat/client', () => ({
AttachmentIcon: () => <span />,
}));
function Wrapper({ provider, children }: { provider?: string; children: React.ReactNode }) {
const methods = useForm<AgentForm>({
defaultValues: { provider: provider as AgentForm['provider'] },
});
return <FormProvider {...methods}>{children}</FormProvider>;
}
describe('Code/Files', () => {
it('renders upload UI when file uploads are not disabled', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
render(
<Wrapper provider="Moonshot">
<Files agent_id="agent-1" />
</Wrapper>,
);
expect(screen.getByText('com_assistants_code_interpreter_files')).toBeInTheDocument();
});
it('returns null when file config is disabled for provider', () => {
mockFileConfig = mergeFileConfig({
endpoints: { Moonshot: { disabled: true }, default: { fileLimit: 10 } },
});
const { container } = render(
<Wrapper provider="Moonshot">
<Files agent_id="agent-1" />
</Wrapper>,
);
expect(container.innerHTML).toBe('');
});
it('returns null when agents endpoint config is disabled and no provider config', () => {
mockFileConfig = mergeFileConfig({
endpoints: { [EModelEndpoint.agents]: { disabled: true }, default: { fileLimit: 10 } },
});
const { container } = render(
<Wrapper>
<Files agent_id="agent-1" />
</Wrapper>,
);
expect(container.innerHTML).toBe('');
});
it('passes provider as endpointOverride and resolved type as endpointTypeOverride', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper provider="Moonshot">
<Files agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe('Moonshot');
expect(params.endpointTypeOverride).toBe(EModelEndpoint.custom);
});
it('falls back to agents for endpointOverride when no provider', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper>
<Files agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe(EModelEndpoint.agents);
expect(params.endpointTypeOverride).toBe(EModelEndpoint.agents);
});
it('falls back to agents for endpointOverride when provider is empty string', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper provider="">
<Files agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe(EModelEndpoint.agents);
});
it('renders when provider has no specific config and agents config is enabled', () => {
mockFileConfig = mergeFileConfig({
endpoints: {
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
render(
<Wrapper provider="Moonshot">
<Files agent_id="agent-1" />
</Wrapper>,
);
expect(screen.getByText('com_assistants_code_interpreter_files')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,151 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import FileContext from '../FileContext';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
let mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetFileConfig: ({ select }: { select?: (d: unknown) => unknown }) => ({
data: select != null ? select(mockFileConfig) : mockFileConfig,
}),
useGetStartupConfig: () => ({ data: { sharePointFilePickerEnabled: false } }),
}));
jest.mock('~/hooks', () => ({
useAgentFileConfig: jest.requireActual('~/hooks/Agents/useAgentFileConfig').default,
useLocalize: () => (key: string) => key,
useLazyEffect: () => {},
}));
const mockUseFileHandlingNoChatContext = jest.fn().mockReturnValue({
handleFileChange: jest.fn(),
});
jest.mock('~/hooks/Files/useFileHandling', () => ({
useFileHandlingNoChatContext: (...args: unknown[]) => mockUseFileHandlingNoChatContext(...args),
}));
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
useSharePointFileHandlingNoChatContext: () => ({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
}),
}));
jest.mock('~/components/SharePoint', () => ({
SharePointPickerDialog: () => null,
}));
jest.mock('~/components/Chat/Input/Files/FileRow', () => () => null);
jest.mock('@ariakit/react', () => ({
MenuButton: ({ children, ...props }: { children: React.ReactNode }) => (
<button {...props}>{children}</button>
),
}));
jest.mock('@librechat/client', () => ({
HoverCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownPopup: () => null,
AttachmentIcon: () => <span />,
CircleHelpIcon: () => <span />,
SharePointIcon: () => <span />,
HoverCardPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HoverCardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HoverCardTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
function Wrapper({ provider, children }: { provider?: string; children: React.ReactNode }) {
const methods = useForm<AgentForm>({
defaultValues: { provider: provider as AgentForm['provider'] },
});
return <FormProvider {...methods}>{children}</FormProvider>;
}
describe('FileContext', () => {
it('renders upload UI when file uploads are not disabled', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
render(
<Wrapper provider="Moonshot">
<FileContext agent_id="agent-1" />
</Wrapper>,
);
expect(screen.getByText('com_agents_file_context_label')).toBeInTheDocument();
});
it('returns null when file config is disabled', () => {
mockFileConfig = mergeFileConfig({
endpoints: { Moonshot: { disabled: true }, default: { fileLimit: 10 } },
});
const { container } = render(
<Wrapper provider="Moonshot">
<FileContext agent_id="agent-1" />
</Wrapper>,
);
expect(container.innerHTML).toBe('');
});
it('returns null when agents endpoint config is disabled and provider has no specific config', () => {
mockFileConfig = mergeFileConfig({
endpoints: { [EModelEndpoint.agents]: { disabled: true }, default: { fileLimit: 10 } },
});
const { container } = render(
<Wrapper>
<FileContext agent_id="agent-1" />
</Wrapper>,
);
expect(container.innerHTML).toBe('');
});
it('passes provider as endpointOverride and resolved type as endpointTypeOverride', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper provider="Moonshot">
<FileContext agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe('Moonshot');
expect(params.endpointTypeOverride).toBe(EModelEndpoint.custom);
});
it('falls back to agents for endpointOverride when no provider', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper>
<FileContext agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe(EModelEndpoint.agents);
expect(params.endpointTypeOverride).toBe(EModelEndpoint.agents);
});
it('renders when provider has no specific config and agents config is enabled', () => {
mockFileConfig = mergeFileConfig({
endpoints: {
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
render(
<Wrapper provider="Moonshot">
<FileContext agent_id="agent-1" />
</Wrapper>,
);
expect(screen.getByText('com_agents_file_context_label')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,147 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { useForm, FormProvider } from 'react-hook-form';
import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import FileSearch from '../FileSearch';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
let mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetFileConfig: ({ select }: { select?: (d: unknown) => unknown }) => ({
data: select != null ? select(mockFileConfig) : mockFileConfig,
}),
useGetStartupConfig: () => ({ data: { sharePointFilePickerEnabled: false } }),
}));
jest.mock('~/hooks', () => ({
useAgentFileConfig: jest.requireActual('~/hooks/Agents/useAgentFileConfig').default,
useLocalize: () => (key: string) => key,
useLazyEffect: () => {},
}));
const mockUseFileHandlingNoChatContext = jest.fn().mockReturnValue({
handleFileChange: jest.fn(),
});
jest.mock('~/hooks/Files/useFileHandling', () => ({
useFileHandlingNoChatContext: (...args: unknown[]) => mockUseFileHandlingNoChatContext(...args),
}));
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
useSharePointFileHandlingNoChatContext: () => ({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
}),
}));
jest.mock('~/components/SharePoint', () => ({
SharePointPickerDialog: () => null,
}));
jest.mock('~/components/Chat/Input/Files/FileRow', () => () => null);
jest.mock('../FileSearchCheckbox', () => () => null);
jest.mock('@ariakit/react', () => ({
MenuButton: ({ children, ...props }: { children: React.ReactNode }) => (
<button {...props}>{children}</button>
),
}));
jest.mock('@librechat/client', () => ({
SharePointIcon: () => <span />,
AttachmentIcon: () => <span />,
DropdownPopup: () => null,
}));
function Wrapper({ provider, children }: { provider?: string; children: React.ReactNode }) {
const methods = useForm<AgentForm>({
defaultValues: { provider: provider as AgentForm['provider'] },
});
return <FormProvider {...methods}>{children}</FormProvider>;
}
describe('FileSearch', () => {
it('renders upload UI when file uploads are not disabled', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
render(
<Wrapper provider="Moonshot">
<FileSearch agent_id="agent-1" />
</Wrapper>,
);
expect(screen.getByText('com_assistants_file_search')).toBeInTheDocument();
});
it('returns null when file config is disabled for provider', () => {
mockFileConfig = mergeFileConfig({
endpoints: { Moonshot: { disabled: true }, default: { fileLimit: 10 } },
});
const { container } = render(
<Wrapper provider="Moonshot">
<FileSearch agent_id="agent-1" />
</Wrapper>,
);
expect(container.innerHTML).toBe('');
});
it('returns null when agents endpoint config is disabled and no provider config', () => {
mockFileConfig = mergeFileConfig({
endpoints: { [EModelEndpoint.agents]: { disabled: true }, default: { fileLimit: 10 } },
});
const { container } = render(
<Wrapper>
<FileSearch agent_id="agent-1" />
</Wrapper>,
);
expect(container.innerHTML).toBe('');
});
it('passes provider as endpointOverride and resolved type as endpointTypeOverride', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper provider="Moonshot">
<FileSearch agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe('Moonshot');
expect(params.endpointTypeOverride).toBe(EModelEndpoint.custom);
});
it('falls back to agents for endpointOverride when no provider', () => {
mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } });
mockUseFileHandlingNoChatContext.mockClear();
render(
<Wrapper>
<FileSearch agent_id="agent-1" />
</Wrapper>,
);
const params = mockUseFileHandlingNoChatContext.mock.calls[0][0];
expect(params.endpointOverride).toBe(EModelEndpoint.agents);
expect(params.endpointTypeOverride).toBe(EModelEndpoint.agents);
});
it('renders when provider has no specific config and agents config is enabled', () => {
mockFileConfig = mergeFileConfig({
endpoints: {
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
render(
<Wrapper provider="Moonshot">
<FileSearch agent_id="agent-1" />
</Wrapper>,
);
expect(screen.getByText('com_assistants_file_search')).toBeInTheDocument();
});
});

View file

@ -5,6 +5,7 @@ export type { ProcessedAgentCategory } from './useAgentCategories';
export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
export { default as useAgentFileConfig } from './useAgentFileConfig';
export { default as useAgentToolPermissions } from './useAgentToolPermissions';
export { default as useMCPToolOptions } from './useMCPToolOptions';
export * from './useApplyModelSpecAgents';

View file

@ -0,0 +1,36 @@
import { useWatch } from 'react-hook-form';
import {
EModelEndpoint,
mergeFileConfig,
resolveEndpointType,
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider';
export default function useAgentFileConfig(): {
endpointType: EModelEndpoint | string | undefined;
providerValue: string | undefined;
endpointFileConfig: EndpointFileConfig;
} {
const providerOption = useWatch<AgentForm>({ name: 'provider' });
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const providerValue =
typeof providerOption === 'string'
? providerOption
: (providerOption as { value?: string } | undefined)?.value;
const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, providerValue);
const endpointFileConfig = getEndpointFileConfig({
fileConfig,
endpointType,
endpoint: providerValue || EModelEndpoint.agents,
});
return { endpointType, providerValue, endpointFileConfig };
}

View file

@ -13,6 +13,7 @@ import {
EModelEndpoint,
mergeFileConfig,
AgentCapabilities,
resolveEndpointType,
isAssistantsEndpoint,
getEndpointFileConfig,
defaultAgentCapabilities,
@ -69,7 +70,19 @@ export default function useDragHelpers() {
(item: { files: File[] }) => {
/** Early block: leverage endpoint file config to prevent drag/drop on disabled endpoints */
const currentEndpoint = conversationRef.current?.endpoint ?? 'default';
const currentEndpointType = conversationRef.current?.endpointType ?? undefined;
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
/** Get agent data from cache; if absent, provider-specific file config restrictions are bypassed client-side */
const agentId = conversationRef.current?.agent_id;
const agent = agentId
? queryClient.getQueryData<t.Agent>([QueryKeys.agent, agentId])
: undefined;
const currentEndpointType = resolveEndpointType(
endpointsConfig,
currentEndpoint,
agent?.provider,
);
const cfg = queryClient.getQueryData<t.FileConfig>([QueryKeys.fileConfig]);
if (cfg) {
const mergedCfg = mergeFileConfig(cfg);
@ -92,27 +105,21 @@ export default function useDragHelpers() {
return;
}
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const agentsConfig = endpointsConfig?.[EModelEndpoint.agents];
const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities;
const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true;
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
const contextEnabled = capabilities.includes(AgentCapabilities.context) === true;
/** Get agent permissions at drop time */
const agentId = conversationRef.current?.agent_id;
let fileSearchAllowedByAgent = true;
let codeAllowedByAgent = true;
if (agentId && !isEphemeralAgent(agentId)) {
/** Agent data from cache */
const agent = queryClient.getQueryData<t.Agent>([QueryKeys.agent, agentId]);
if (agent) {
const agentTools = agent.tools as string[] | undefined;
fileSearchAllowedByAgent = agentTools?.includes(Tools.file_search) ?? false;
codeAllowedByAgent = agentTools?.includes(Tools.execute_code) ?? false;
} else {
/** If agent exists but not found, disallow */
fileSearchAllowedByAgent = false;
codeAllowedByAgent = false;
}

View file

@ -30,8 +30,10 @@ type UseFileHandling = {
fileSetter?: FileSetter;
fileFilter?: (file: File) => boolean;
additionalMetadata?: Record<string, string | undefined>;
/** Overrides both `endpoint` and `endpointType` for validation and upload routing */
endpointOverride?: EModelEndpoint;
/** Overrides `endpoint` for upload routing; also used as `endpointType` fallback when `endpointTypeOverride` is not set */
endpointOverride?: EModelEndpoint | string;
/** Overrides `endpointType` independently from `endpointOverride` */
endpointTypeOverride?: EModelEndpoint | string;
};
export type FileHandlingState = {
@ -64,9 +66,10 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
const agent_id = params?.additionalMetadata?.agent_id ?? '';
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
const endpointOverride = params?.endpointOverride;
const endpointTypeOverride = params?.endpointTypeOverride;
const endpointType = useMemo(
() => endpointOverride ?? conversation?.endpointType,
[endpointOverride, conversation?.endpointType],
() => endpointTypeOverride ?? endpointOverride ?? conversation?.endpointType,
[endpointTypeOverride, endpointOverride, conversation?.endpointType],
);
const endpoint = useMemo(
() => endpointOverride ?? conversation?.endpoint ?? 'default',

View file

@ -10,7 +10,8 @@ interface UseSharePointFileHandlingProps {
toolResource?: string;
fileFilter?: (file: File) => boolean;
additionalMetadata?: Record<string, string | undefined>;
endpointOverride?: EModelEndpoint;
endpointOverride?: EModelEndpoint | string;
endpointTypeOverride?: EModelEndpoint | string;
}
interface UseSharePointFileHandlingReturn {