🏪 feat: Agent Marketplace

bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status

refactored and moved agent category methods and schema to data-schema package

🔧 fix: Merge and Rebase Conflicts

- Move AgentCategory from api/models to @packages/data-schemas structure
  - Add schema, types, methods, and model following codebase conventions
  - Implement auto-seeding of default categories during AppService startup
  - Update marketplace controller to use new data-schemas methods
  - Remove old model file and standalone seed script

refactor: unify agent marketplace to single endpoint with cursor pagination

  - Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests

feat: add icon property to ProcessedAgentCategory interface

- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
  - Replace manual pagination in AgentGrid with infinite query pattern
  - Update imports to use local data provider instead of librechat-data-provider
  - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
  - Improve agent access control by adding requiredPermission validation in backend
  - Remove manual cursor/state management in favor of infinite query built-ins
  - Maintain existing search and category filtering functionality

refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency

  - Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
  - Add countPromotedAgents function to Agent model for promoted agents count
  - Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
  - Move getAgentCategories from marketplace to v1 controller with same functionality
  - Update agent mutations to invalidate marketplace queries and handle multiple permission levels
  - Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
  - Consolidate agent data access patterns for better maintainability and consistency
  - Remove duplicate marketplace route definitions and middleware

selected view only agents injected in the drop down

fix: remove minlength validation for support contact name in agent schema

feat: add validation and error messages for agent name in AgentConfig and AgentPanel

fix: update agent permission check logic in AgentPanel to simplify condition

Fix linting WIP

Fix Unit tests WIP

ESLint fixes

eslint fix

refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic

- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.

refactor: remove redundant properties from IAgent interface in agent schema

chore: update localization for agent detail component and clean up imports

ci: update access middleware tests

chore: remove unused PermissionTypes import from Role model

ci: update AclEntry model tests

ci: update button accessibility labels in AgentDetail tests

refactor: update exhaustive dep. lint warning

🔧 fix: Fixed agent actions access

feat: Add role-level permissions for agent sharing people picker

  - Add PEOPLE_PICKER permission type with VIEW_USERS and VIEW_GROUPS permissions
  - Create custom middleware for query-aware permission validation
  - Implement permission-based type filtering in PeoplePicker component
  - Hide people picker UI when user lacks permissions, show only public toggle
  - Support granular access: users-only, groups-only, or mixed search modes

refactor: Replace marketplace interface config with permission-based system

  - Add MARKETPLACE permission type to handle marketplace access control
  - Update interface configuration to use role-based marketplace settings (admin/user)
  - Replace direct marketplace boolean config with permission-based checks
  - Modify frontend components to use marketplace permissions instead of interface config
  - Update agent query hooks to use marketplace permissions for determining permission levels
  - Add marketplace configuration structure similar to peoplePicker in YAML config
  - Backend now sets MARKETPLACE permissions based on interface configuration
  - When marketplace enabled: users get agents with EDIT permissions in dropdown lists  (builder mode)
  - When marketplace disabled: users get agents with VIEW permissions  in dropdown lists (browse mode)

🔧 fix: Redirect to New Chat if No Marketplace Access and Required Agent Name Placeholder (#8213)

* Fix: Fix the redirect to new chat page if access to marketplace is denied

* Fixed the required agent name placeholder

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>

chore: fix tests, remove unnecessary imports

refactor: Implement permission checks for file access via agents

- Updated `hasAccessToFilesViaAgent` to utilize permission checks for VIEW and EDIT access.
- Replaced project-based access validation with permission-based checks.
- Enhanced tests to cover new permission logic and ensure proper access control for files associated with agents.
- Cleaned up imports and initialized models in test files for consistency.

refactor: Enhance test setup and cleanup for file access control

- Introduced modelsToCleanup array to track models added during tests for proper cleanup.
- Updated afterAll hooks in test files to ensure all collections are cleared and only added models are deleted.
- Improved consistency in model initialization across test files.
- Added comments for clarity on cleanup processes and test data management.

chore: Update Jest configuration and test setup for improved timeout handling

- Added a global test timeout of 30 seconds in jest.config.js.
- Configured jest.setTimeout in jestSetup.js to allow individual test overrides if needed.
- Enhanced test reliability by ensuring consistent timeout settings across all tests.

refactor: Implement file access filtering based on agent permissions

- Introduced `filterFilesByAgentAccess` function to filter files based on user access through agents.
- Updated `getFiles` and `primeFiles` functions to utilize the new filtering logic.
- Moved `hasAccessToFilesViaAgent` function from the File model to permission services, adjusting imports accordingly
- Enhanced tests to ensure proper access control and filtering behavior for files associated with agents.

fix: make support_contact field a nested object rather than a sub-document

refactor: Update support_contact field initialization in agent model

- Removed handling for empty support_contact object in createAgent function.
- Changed default value of support_contact in agent schema to undefined.

test: Add comprehensive tests for support_contact field handling and versioning

refactor: remove unused avatar upload mutation field and add informational toast for success

chore: add missing SidePanelProvider for AgentMarketplace and organize imports

fix: resolve agent selection race condition in marketplace HandleStartChat
- Set agent in localStorage before newConversation to prevent useSelectorEffects from auto-selecting previous agent

fix: resolve agent dropdown showing raw ID instead of agent info from URL

  - Add proactive agent fetching when agent_id is present in URL parameters
  - Inject fetched agent into agents cache so dropdowns display proper name/avatar
  - Use useAgentsMap dependency to ensure proper cache initialization timing
  - Prevents raw agent IDs from showing in UI when visiting shared agent links

Fix: Agents endpoint renamed to "My Agent" for less confusion with the Marketplace agents.

chore: fix ESLint issues and Test Mocks

ci: update permissions structure in loadDefaultInterface tests

- Refactored permissions for MEMORY and added new permissions for MARKETPLACE and PEOPLE_PICKER.
- Ensured consistent structure for permissions across different types.

feat:  support_contact validation to allow empty email strings
This commit is contained in:
“Praneeth 2025-06-11 22:55:07 +05:30 committed by Danny Avila
parent 66bd419baa
commit 949682ef0f
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
95 changed files with 3770 additions and 2728 deletions

View file

@ -1,6 +1,8 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import useAgentCategories from '../useAgentCategories';
import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// Mock the useLocalize hook
jest.mock('~/hooks/useLocalize', () => ({
@ -11,25 +13,68 @@ jest.mock('~/hooks/useLocalize', () => ({
},
}));
describe('useAgentCategories', () => {
it('should return processed categories with correct structure', () => {
const { result } = renderHook(() => useAgentCategories());
// Mock the data provider
jest.mock('~/data-provider/Agents', () => ({
useGetAgentCategoriesQuery: jest.fn(() => ({
data: [
{ value: 'general', label: 'com_ui_agent_category_general' },
{ value: 'hr', label: 'com_ui_agent_category_hr' },
{ value: 'rd', label: 'com_ui_agent_category_rd' },
{ value: 'finance', label: 'com_ui_agent_category_finance' },
{ value: 'it', label: 'com_ui_agent_category_it' },
{ value: 'sales', label: 'com_ui_agent_category_sales' },
{ value: 'aftersales', label: 'com_ui_agent_category_aftersales' },
{ value: 'promoted', label: 'Promoted' }, // Should be filtered out
{ value: 'all', label: 'All' }, // Should be filtered out
],
isLoading: false,
error: null,
})),
}));
// Check that we have the expected number of categories
expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useAgentCategories', () => {
it('should return processed categories with correct structure', async () => {
const { result } = renderHook(() => useAgentCategories(), {
wrapper: createWrapper(),
});
await waitFor(() => {
// Check that we have the expected number of categories (excluding 'promoted' and 'all')
expect(result.current.categories.length).toBe(7);
});
// Check that the first category has the expected structure
const firstCategory = result.current.categories[0];
const firstOriginalCategory = AGENT_CATEGORIES[0];
expect(firstCategory.value).toBe(firstOriginalCategory.value);
// Check that labels are properly translated
expect(firstCategory.label).toBe('General (Translated)');
expect(firstCategory.value).toBe('general');
expect(firstCategory.label).toBe('com_ui_agent_category_general');
expect(firstCategory.className).toBe('w-full');
// Verify special categories are filtered out
const categoryValues = result.current.categories.map((cat) => cat.value);
expect(categoryValues).not.toContain('promoted');
expect(categoryValues).not.toContain('all');
// Check the empty category
expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value);
expect(result.current.emptyCategory.label).toBeTruthy();
expect(result.current.emptyCategory.label).toBe('General (Translated)');
expect(result.current.emptyCategory.className).toBe('w-full');
// Check loading state
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
});

View file

@ -1,360 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useDynamicAgentQuery } from '../useDynamicAgentQuery';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
// Mock the data provider queries
jest.mock('~/data-provider', () => ({
useGetPromotedAgentsQuery: jest.fn(),
useGetAgentsByCategoryQuery: jest.fn(),
useSearchAgentsQuery: jest.fn(),
}));
const mockUseGetPromotedAgentsQuery = useGetPromotedAgentsQuery as jest.MockedFunction<
typeof useGetPromotedAgentsQuery
>;
const mockUseGetAgentsByCategoryQuery = useGetAgentsByCategoryQuery as jest.MockedFunction<
typeof useGetAgentsByCategoryQuery
>;
const mockUseSearchAgentsQuery = useSearchAgentsQuery as jest.MockedFunction<
typeof useSearchAgentsQuery
>;
describe('useDynamicAgentQuery', () => {
const defaultMockQueryResult = {
data: undefined,
isLoading: false,
error: null,
isFetching: false,
refetch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Set default mock returns
mockUseGetPromotedAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseGetAgentsByCategoryQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseSearchAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
});
describe('Search Query Type', () => {
it('should use search query when searchQuery is provided', () => {
const mockSearchResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseSearchAgentsQuery.mockReturnValue(mockSearchResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
// Should call search query with correct parameters
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
category: 'hr',
page: 1,
limit: 6,
},
expect.objectContaining({
enabled: true,
staleTime: 120000,
refetchOnWindowFocus: false,
keepPreviousData: true,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 1,
}),
);
// Should return search query result
expect(result.current.data).toBe(mockSearchResult.data);
expect(result.current.queryType).toBe('search');
});
it('should not include category in search when category is "all" or "promoted"', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
page: 1,
limit: 6,
// No category parameter should be included
},
expect.any(Object),
);
});
});
describe('Promoted Query Type', () => {
it('should use promoted query when category is "promoted" and no search', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '',
page: 2,
limit: 8,
}),
);
// Should call promoted query with correct parameters (no showAll)
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 2,
limit: 8,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockPromotedResult.data);
expect(result.current.queryType).toBe('promoted');
});
});
describe('All Agents Query Type', () => {
it('should use promoted query with showAll when category is "all" and no search', () => {
const mockAllResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
// Mock the second call to useGetPromotedAgentsQuery (for "all" category)
mockUseGetPromotedAgentsQuery
.mockReturnValueOnce(defaultMockQueryResult as any) // First call for promoted
.mockReturnValueOnce(mockAllResult as any); // Second call for all
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should call promoted query with showAll parameter
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 1,
limit: 6,
showAll: 'true',
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.queryType).toBe('all');
});
});
describe('Category Query Type', () => {
it('should use category query for specific categories', () => {
const mockCategoryResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockCategoryResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'finance',
searchQuery: '',
page: 3,
limit: 10,
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'finance',
page: 3,
limit: 10,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockCategoryResult.data);
expect(result.current.queryType).toBe('category');
});
});
describe('Query Configuration', () => {
it('should apply correct query configuration to all queries', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
const expectedConfig = expect.objectContaining({
staleTime: 120000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
});
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expectedConfig,
);
});
it('should enable only the correct query based on query type', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Category query should be enabled
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
// Other queries should be disabled
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
});
describe('Default Parameters', () => {
it('should use default page and limit when not provided', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'general',
searchQuery: '',
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'general',
page: 1,
limit: 6,
},
expect.any(Object),
);
});
});
describe('Return Values', () => {
it('should return all necessary query properties', () => {
const mockResult = {
data: { agents: [{ id: '1', name: 'Test Agent' }] },
isLoading: true,
error: null,
isFetching: false,
refetch: jest.fn(),
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'it',
searchQuery: '',
page: 1,
limit: 6,
}),
);
expect(result.current).toEqual({
data: mockResult.data,
isLoading: mockResult.isLoading,
error: mockResult.error,
isFetching: mockResult.isFetching,
refetch: mockResult.refetch,
queryType: 'category',
});
});
});
describe('Edge Cases', () => {
it('should handle empty search query as no search', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '', // Empty string should not trigger search
page: 1,
limit: 6,
}),
);
// Should use promoted query, not search query
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
it('should fallback to promoted query for unknown query types', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [] },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'unknown-category',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should determine this as 'category' type and use category query
expect(result.current.queryType).toBe('category');
});
});
});

View file

@ -1,7 +1,6 @@
export { default as useAgentsMap } from './useAgentsMap';
export { default as useSelectAgent } from './useSelectAgent';
export { default as useAgentCategories } from './useAgentCategories';
export { useDynamicAgentQuery } from './useDynamicAgentQuery';
export type { ProcessedAgentCategory } from './useAgentCategories';
export { default as useAgentCapabilities } from './useAgentCapabilities';
export { default as useGetAgentsConfig } from './useGetAgentsConfig';

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import useLocalize from '~/hooks/useLocalize';
import { useGetAgentCategoriesQuery } from '~/data-provider';
import { useGetAgentCategoriesQuery } from '~/data-provider/Agents';
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// This interface matches the structure used by the ControlCombobox component
@ -9,6 +9,7 @@ export interface ProcessedAgentCategory {
label: string; // Translated label
value: string; // Category value
className?: string;
icon?: string;
}
/**

View file

@ -1,6 +1,6 @@
import { TAgentsMap } from 'librechat-data-provider';
import { useMemo } from 'react';
import { useListAgentsQuery } from '~/data-provider';
import { useListAgentsQuery, useAgentListingDefaultPermissionLevel } from '~/data-provider';
import { mapAgents } from '~/utils';
export default function useAgentsMap({
@ -8,10 +8,15 @@ export default function useAgentsMap({
}: {
isAuthenticated: boolean;
}): TAgentsMap | undefined {
const { data: agentsList = null } = useListAgentsQuery(undefined, {
select: (res) => mapAgents(res.data),
enabled: isAuthenticated,
});
const permissionLevel = useAgentListingDefaultPermissionLevel();
const { data: agentsList = null } = useListAgentsQuery(
{ requiredPermission: permissionLevel },
{
select: (res) => mapAgents(res.data),
enabled: isAuthenticated,
},
);
const agents = useMemo<TAgentsMap | undefined>(() => {
return agentsList !== null ? agentsList : undefined;

View file

@ -1,112 +0,0 @@
import { useMemo } from 'react';
import type { UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
interface UseDynamicAgentQueryParams {
category: string;
searchQuery: string;
page?: number;
limit?: number;
}
/**
* Single dynamic query hook that replaces 4 separate conditional queries
* Determines the appropriate query based on category and search state
*/
export const useDynamicAgentQuery = ({
category,
searchQuery,
page = 1,
limit = 6,
}: UseDynamicAgentQueryParams) => {
// Shared query configuration optimized to prevent unnecessary loading states
const queryConfig: UseQueryOptions<t.AgentListResponse> = useMemo(
() => ({
staleTime: 1000 * 60 * 2, // 2 minutes - agents don't change frequently
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
// Removed placeholderData due to TypeScript compatibility - keepPreviousData is sufficient
}),
[],
);
// Determine query type and parameters based on current state
const queryType = useMemo(() => {
if (searchQuery) return 'search';
if (category === 'promoted') return 'promoted';
if (category === 'all') return 'all';
return 'category';
}, [category, searchQuery]);
// Search query - when user is searching
const searchQuery_result = useSearchAgentsQuery(
{
q: searchQuery,
...(category !== 'all' && category !== 'promoted' && { category }),
page,
limit,
},
{
...queryConfig,
enabled: queryType === 'search',
},
);
// Promoted agents query - for "Top Picks" tab
const promotedQuery = useGetPromotedAgentsQuery(
{ page, limit },
{
...queryConfig,
enabled: queryType === 'promoted',
},
);
// All agents query - for "All" tab (promoted endpoint with showAll parameter)
const allAgentsQuery = useGetPromotedAgentsQuery(
{ page, limit, showAll: 'true' },
{
...queryConfig,
enabled: queryType === 'all',
},
);
// Category-specific query - for individual categories
const categoryQuery = useGetAgentsByCategoryQuery(
{ category, page, limit },
{
...queryConfig,
enabled: queryType === 'category',
},
);
// Return the active query based on current state
const activeQuery = useMemo(() => {
switch (queryType) {
case 'search':
return searchQuery_result;
case 'promoted':
return promotedQuery;
case 'all':
return allAgentsQuery;
case 'category':
return categoryQuery;
default:
return promotedQuery; // fallback
}
}, [queryType, searchQuery_result, promotedQuery, allAgentsQuery, categoryQuery]);
return {
...activeQuery,
queryType, // Expose query type for debugging/logging
};
};

View file

@ -8,6 +8,7 @@ import {
isAgentsEndpoint,
getConfigDefaults,
isAssistantsEndpoint,
PERMISSION_BITS,
} from 'librechat-data-provider';
import type { TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider';
import type { MentionOption } from '~/common';
@ -79,28 +80,31 @@ export default function useMentions({
() => startupConfig?.interface ?? defaultInterface,
[startupConfig?.interface],
);
const { data: agentsList = null } = useListAgentsQuery(undefined, {
enabled: hasAgentAccess && interfaceConfig.modelSelect === true,
select: (res) => {
const { data } = res;
return data.map(({ id, name, avatar }) => ({
value: id,
label: name ?? '',
type: EModelEndpoint.agents,
icon: EndpointIcon({
conversation: {
agent_id: id,
endpoint: EModelEndpoint.agents,
iconURL: avatar?.filepath,
},
containerClassName: 'shadow-stroke overflow-hidden rounded-full',
endpointsConfig: endpointsConfig,
context: 'menu-item',
size: 20,
}),
}));
const { data: agentsList = null } = useListAgentsQuery(
{ requiredPermission: PERMISSION_BITS.VIEW },
{
enabled: hasAgentAccess && interfaceConfig.modelSelect === true,
select: (res) => {
const { data } = res;
return data.map(({ id, name, avatar }) => ({
value: id,
label: name ?? '',
type: EModelEndpoint.agents,
icon: EndpointIcon({
conversation: {
agent_id: id,
endpoint: EModelEndpoint.agents,
iconURL: avatar?.filepath,
},
containerClassName: 'shadow-stroke overflow-hidden rounded-full',
endpointsConfig: endpointsConfig,
context: 'menu-item',
size: 20,
}),
}));
},
},
});
);
const assistantListMap = useMemo(
() => ({
[EModelEndpoint.assistants]: listMap[EModelEndpoint.assistants]

View file

@ -34,6 +34,7 @@ jest.mock('react-router-dom', () => ({
jest.mock('@tanstack/react-query', () => ({
useQueryClient: jest.fn(),
useQuery: jest.fn(),
}));
jest.mock('~/Providers', () => ({
@ -51,6 +52,15 @@ jest.mock('~/hooks/Conversations/useDefaultConvo', () => ({
default: jest.fn(),
}));
jest.mock('~/hooks/AuthContext', () => ({
useAuthContext: jest.fn(),
}));
jest.mock('~/hooks/Agents/useAgentsMap', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('~/utils', () => ({
getConvoSwitchLogic: jest.fn(() => ({
template: {},
@ -63,6 +73,8 @@ jest.mock('~/utils', () => ({
getModelSpecIconURL: jest.fn(() => 'icon-url'),
removeUnavailableTools: jest.fn((preset) => preset),
logger: { log: jest.fn() },
getInitialTheme: jest.fn(() => 'light'),
applyFontSize: jest.fn(),
}));
// Mock the tQueryParamsSchema
@ -82,6 +94,21 @@ jest.mock('librechat-data-provider', () => ({
EModelEndpoint: { custom: 'custom', assistants: 'assistants', agents: 'agents' },
}));
// Mock data-provider hooks
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
useAgentListingDefaultPermissionLevel: jest.fn(() => 'view'),
useListAgentsQuery: jest.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
}));
// Mock global window.history
global.window = Object.create(window);
global.window.history = {
@ -103,6 +130,14 @@ describe('useQueryParams', () => {
// Reset mock for window.history.replaceState
jest.spyOn(window.history, 'replaceState').mockClear();
// Reset data-provider mocks
const dataProvider = jest.requireMock('~/data-provider');
(dataProvider.useGetAgentByIdQuery as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
error: null,
});
// Create mocks for all dependencies
const mockSearchParams = new URLSearchParams();
(useSearchParams as jest.Mock).mockReturnValue([mockSearchParams, jest.fn()]);
@ -147,6 +182,13 @@ describe('useQueryParams', () => {
const mockGetDefaultConversation = jest.fn().mockReturnValue({});
(useDefaultConvo as jest.Mock).mockReturnValue(mockGetDefaultConversation);
// Mock useAuthContext
const { useAuthContext } = jest.requireMock('~/hooks/AuthContext');
(useAuthContext as jest.Mock).mockReturnValue({
user: { id: 'test-user-id' },
isAuthenticated: true,
});
});
afterEach(() => {

View file

@ -1,20 +1,26 @@
import { useEffect, useCallback, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryClient, useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
EModelEndpoint,
isAgentsEndpoint,
tQueryParamsSchema,
isAssistantsEndpoint,
PERMISSION_BITS,
} from 'librechat-data-provider';
import type {
TPreset,
TEndpointsConfig,
TStartupConfig,
AgentListResponse,
} from 'librechat-data-provider';
import type { TPreset, TEndpointsConfig, TStartupConfig } from 'librechat-data-provider';
import type { ZodAny } from 'zod';
import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils';
import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo';
import { useAuthContext, useAgentsMap, useDefaultConvo, useSubmitMessage } from '~/hooks';
import { useChatContext, useChatFormContext } from '~/Providers';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import { useGetAgentByIdQuery } from '~/data-provider';
import store from '~/store';
/**
@ -73,6 +79,21 @@ const processValidSettings = (queryParams: Record<string, string>) => {
return validSettings;
};
const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => {
const editCacheKey = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }];
const editCache = queryClient.getQueryData<AgentListResponse>(editCacheKey);
if (editCache?.data && !editCache.data.some((cachedAgent) => cachedAgent.id === agent.id)) {
// Inject agent into EDIT cache so dropdown can display it
const updatedCache = {
...editCache,
data: [agent, ...editCache.data],
};
queryClient.setQueryData(editCacheKey, updatedCache);
logger.log('agent', 'Injected URL agent into cache:', agent);
}
};
/**
* Hook that processes URL query parameters to initialize chat with specified settings and prompt.
* Handles model switching, prompt auto-filling, and optional auto-submission with race condition protection.
@ -104,6 +125,14 @@ export default function useQueryParams({
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
// Extract agent_id from URL for proactive fetching
const urlAgentId = searchParams.get('agent_id') || '';
// Use the existing query hook to fetch agent if present in URL
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId, {
enabled: !!urlAgentId, // Only fetch if agent_id exists in URL
});
/**
* Applies settings from URL query parameters to create a new conversation.
* Handles model spec lookup, endpoint normalization, and conversation switching logic.
@ -418,4 +447,12 @@ export default function useQueryParams({
}
}
}, [conversation, processSubmission, areSettingsApplied]);
const { isAuthenticated } = useAuthContext();
const agentsMap = useAgentsMap({ isAuthenticated });
useEffect(() => {
if (urlAgent) {
injectAgentIntoAgentsMap(queryClient, urlAgent);
}
}, [urlAgent, queryClient, agentsMap]);
}