📇 feat: Agent Contact Visibility with Owner Fallback (#13663)

* Shared Contract

* Backend Resolution

* Frontend Display

* Contact Styling and more tests

* fix contact flicker when saving an agent

* fix display owner when contact deleted

* simplification of the last fixes

* github action fixes

* fixes failing tests

---------

Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
This commit is contained in:
Peter 2026-06-25 21:58:15 +02:00 committed by GitHub
parent 376370d610
commit abf9fc307d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 894 additions and 111 deletions

View file

@ -48,6 +48,7 @@ const {
resolveConfigServers,
userCanUseMCPServers,
} = require('~/server/services/MCP');
const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
const { getMCPServersRegistry } = require('~/config');
const { getLogStores } = require('~/cache');
const db = require('~/models');
@ -517,13 +518,15 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
});
agent.isPublic = isPublic;
await attachOwnerContacts([agent]);
if (agent.author !== author) {
delete agent.author;
}
if (!expandProperties) {
// VIEW permission: Basic agent info only
return res.status(200).json({
const responseAgent = {
_id: agent._id,
id: agent.id,
name: agent.name,
@ -538,7 +541,16 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
});
};
if (agent.support_contact !== undefined) {
responseAgent.support_contact = agent.support_contact;
}
if (agent.owner_contact !== undefined) {
responseAgent.owner_contact = agent.owner_contact;
}
return res.status(200).json(responseAgent);
}
// EDIT permission: Full agent details including sensitive configuration
@ -710,6 +722,8 @@ const updateAgentHandler = async (req, res) => {
updatedAgent.author = updatedAgent.author.toString();
}
await attachOwnerContacts([updatedAgent]);
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
@ -1045,9 +1059,10 @@ const getListAgentsHandler = async (req, res) => {
}
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
const agentsWithContacts = await attachOwnerContacts(agents);
const urlCache = cachedRefresh?.urlCache;
data.data = agents.map((agent) => {
data.data = agentsWithContacts.map((agent) => {
if (accessibleSkillSet) {
sanitizeViewerSkillScope(agent, accessibleSkillSet);
}
@ -1153,6 +1168,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
await attachOwnerContacts([updatedAgent]);
try {
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
@ -1257,6 +1273,8 @@ const revertAgentVersionHandler = async (req, res) => {
updatedAgent.author = updatedAgent.author.toString();
}
await attachOwnerContacts([updatedAgent]);
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}

View file

@ -1,8 +1,14 @@
const mongoose = require('mongoose');
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { agentSchema, fileSchema } = require('@librechat/data-schemas');
const { FileSources, PermissionBits, ResourceType } = require('librechat-data-provider');
const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas');
const {
FileSources,
PermissionBits,
PrincipalModel,
PrincipalType,
ResourceType,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
// Only mock the dependencies that are not database-related
@ -91,6 +97,32 @@ const { refreshS3Url } = require('@librechat/api');
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
*/
let Agent;
let AclEntry;
let User;
const OWNER_PERMISSION_BITS =
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE;
const createOwner = (overrides = {}) =>
User.create({
name: 'Agent Owner',
email: `owner-${nanoid(8)}@example.com`,
provider: 'local',
emailVerified: true,
...overrides,
});
const grantAgentOwner = ({ agent, owner, grantedAt = new Date() }) =>
AclEntry.create({
principalType: PrincipalType.USER,
principalModel: PrincipalModel.USER,
principalId: owner._id,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: OWNER_PERMISSION_BITS,
grantedBy: owner._id,
grantedAt,
});
describe('Agent Controllers - Mass Assignment Protection', () => {
let mongoServer;
@ -102,6 +134,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
AclEntry = mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema);
User = mongoose.models.User || mongoose.model('User', userSchema);
// Register File so orphan-pruning tests (and the tool_resources validation
// test, which now needs real File docs for its ids) have a working model.
mongoose.models.File || mongoose.model('File', fileSchema);
@ -114,6 +148,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
beforeEach(async () => {
await Agent.deleteMany({});
await AclEntry.deleteMany({});
await User.deleteMany({});
await mongoose.models.File.deleteMany({});
// Reset all mocks
@ -510,6 +546,61 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.model_parameters.temperature).toBeUndefined();
expect(response.model_parameters.apiKey).toBeUndefined();
});
test('should return owner_contact from the first ACL owner when support_contact is missing', async () => {
const owner = await createOwner({
name: 'Primary Owner',
email: 'primary.owner@example.com',
});
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Owner Contact Agent',
description: 'Uses owner fallback',
provider: 'openai',
model: 'gpt-4',
author: owner._id,
});
await grantAgentOwner({ agent, owner });
mockReq.params = { id: agent.id };
await getAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const response = mockRes.json.mock.calls[0][0];
expect(response.owner_contact).toEqual({
name: 'Primary Owner',
email: 'primary.owner@example.com',
});
});
test('should not return owner_contact when support_contact is present', async () => {
const owner = await createOwner({
name: 'Primary Owner',
email: 'primary.owner@example.com',
});
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Support Contact Agent',
description: 'Uses support contact',
provider: 'openai',
model: 'gpt-4',
author: owner._id,
support_contact: { name: 'Support Team', email: 'support@example.com' },
});
await grantAgentOwner({ agent, owner });
mockReq.params = { id: agent.id };
await getAgentHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.support_contact).toEqual({
name: 'Support Team',
email: 'support@example.com',
});
expect(response.owner_contact).toBeUndefined();
});
});
describe('updateAgentHandler', () => {
@ -1303,6 +1394,71 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].name).toBe('Agent A1');
});
test('should return owner_contact for list agents missing support_contact', async () => {
const owner = await createOwner({
_id: userA,
name: 'List Owner',
email: 'list.owner@example.com',
});
await grantAgentOwner({ agent: agentA1, owner });
mockReq.user.id = userB.toString();
findAccessibleResources.mockResolvedValue([agentA1._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data[0].owner_contact).toEqual({
name: 'List Owner',
email: 'list.owner@example.com',
});
});
test('should use the first ACL owner when an agent has multiple owners', async () => {
const firstOwner = await createOwner({
name: 'First Owner',
email: 'first.owner@example.com',
});
const secondOwner = await createOwner({
name: 'Second Owner',
email: 'second.owner@example.com',
});
await grantAgentOwner({
agent: agentA1,
owner: secondOwner,
grantedAt: new Date('2024-02-01T00:00:00.000Z'),
});
await grantAgentOwner({
agent: agentA1,
owner: firstOwner,
grantedAt: new Date('2024-01-01T00:00:00.000Z'),
});
mockReq.user.id = userB.toString();
findAccessibleResources.mockResolvedValue([agentA1._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data[0].owner_contact).toEqual({
name: 'First Owner',
email: 'first.owner@example.com',
});
});
test('should omit owner_contact when no owner user can be resolved', async () => {
mockReq.user.id = userB.toString();
findAccessibleResources.mockResolvedValue([agentA1._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data[0].owner_contact).toBeUndefined();
});
test('should return only expected safe list fields for VIEW callers', async () => {
const hiddenSkillId = new mongoose.Types.ObjectId();
await Agent.findByIdAndUpdate(agentA1._id, {

View file

@ -22,6 +22,7 @@ const {
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
const db = require('~/models');
const { canAccessAgentResource } = require('~/server/middleware');
@ -209,6 +210,7 @@ router.post(
forceVersion: true,
},
);
await attachOwnerContacts([updatedAgent]);
// Only update user field for new actions
const actionUpdateData = {

View file

@ -0,0 +1,87 @@
const { logger } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
const { hasSupportContact, resolveAgentOwnerContact } = require('@librechat/api');
const db = require('~/models');
const OWNER_PERMISSION_BITS =
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE;
const getFirstOwnerIdsByResource = async (agents) => {
const resourceIds = agents
.filter((agent) => !hasSupportContact(agent))
.map((agent) => agent?._id)
.filter(Boolean);
if (resourceIds.length === 0) {
return new Map();
}
try {
const entries = await db.aggregateAclEntries([
{
$match: {
resourceType: ResourceType.AGENT,
resourceId: { $in: resourceIds },
principalType: PrincipalType.USER,
permBits: OWNER_PERMISSION_BITS,
},
},
{ $sort: { grantedAt: 1, createdAt: 1, _id: 1 } },
{ $group: { _id: '$resourceId', principalId: { $first: '$principalId' } } },
]);
return new Map(
entries
.map((entry) => [entry?._id?.toString(), entry?.principalId?.toString()])
.filter(([resourceId, ownerId]) => resourceId && ownerId),
);
} catch (error) {
logger.warn('[/Agents] Failed to resolve agent owner ACL entries', error);
return new Map();
}
};
const attachOwnerContacts = async (agents) => {
if (!Array.isArray(agents) || agents.length === 0) {
return agents;
}
const ownerIdsByResource = await getFirstOwnerIdsByResource(agents);
const ownerIds = [
...new Set(
agents
.filter((agent) => !hasSupportContact(agent))
.map((agent) => ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString())
.filter(Boolean),
),
];
let ownersById = new Map();
if (ownerIds.length > 0) {
try {
const users = await db.findUsers({ _id: { $in: ownerIds } }, 'name username email');
ownersById = new Map(users.map((user) => [user?._id?.toString(), user]));
} catch (error) {
logger.warn('[/Agents] Failed to resolve agent owner users', error);
}
}
return agents.map((agent) => {
if (hasSupportContact(agent)) {
delete agent.owner_contact;
return agent;
}
const ownerId = ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString();
const ownerContact = resolveAgentOwnerContact(agent, ownersById.get(ownerId) ?? null);
if (ownerContact) {
agent.owner_contact = ownerContact;
} else {
delete agent.owner_contact;
}
return agent;
});
};
module.exports = {
attachOwnerContacts,
};

View file

@ -2,8 +2,9 @@ import React, { useMemo, useState } from 'react';
import { Label, OGDialog, OGDialogTrigger } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import AgentDetailContent from './AgentDetailContent';
import { cn, renderAgentAvatar } from '~/utils';
import AgentContact from './AgentContact';
interface AgentCardProps {
agent: t.Agent;
@ -33,8 +34,6 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onSelect, className = '' }
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
}, [agent.category, categories, localize]);
const displayName = getContactDisplayName(agent);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open && onSelect) {
@ -102,14 +101,10 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onSelect, className = '' }
</p>
)}
{/* Author */}
{displayName && (
<div className="mt-1 text-xs text-text-tertiary">
<span className="truncate">
{localize('com_ui_by_author', { 0: displayName || '' })}
</span>
</div>
)}
<AgentContact
agent={agent}
className="mt-1 text-xs text-text-secondary [&_a]:font-normal [&_a]:text-text-secondary"
/>
</div>
</div>
</OGDialogTrigger>

View file

@ -0,0 +1,39 @@
import type t from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type AgentContactProps = {
agent?: Pick<t.Agent, 'support_contact' | 'owner_contact'> | null;
className?: string;
};
export default function AgentContact({ agent, className = '' }: AgentContactProps) {
const localize = useLocalize();
const supportName = agent?.support_contact?.name?.trim() ?? '';
const supportEmail = agent?.support_contact?.email?.trim() ?? '';
const ownerName = agent?.owner_contact?.name?.trim() ?? '';
const ownerEmail = agent?.owner_contact?.email?.trim() ?? '';
let contact: { name: string; email: string } | null = null;
if (supportName || supportEmail) {
contact = { name: supportName, email: supportEmail };
} else if (ownerName || ownerEmail) {
contact = { name: ownerName, email: ownerEmail };
}
const label = contact?.name || contact?.email || localize('com_agents_no_contact_available');
return (
<div className={cn('flex min-w-0 items-center gap-1 text-text-secondary', className)}>
<span className="shrink-0">{localize('com_agents_contact')}:</span>
<span className="min-w-0 truncate">
{contact?.email ? (
<a href={`mailto:${contact.email}`} className="text-primary hover:underline">
{label}
</a>
) : (
label
)}
</span>
</div>
);
}

View file

@ -14,18 +14,10 @@ import type t from 'librechat-data-provider';
import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks';
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useChatContext } from '~/Providers';
interface SupportContact {
name?: string;
email?: string;
}
interface AgentWithSupport extends t.Agent {
support_contact?: SupportContact;
}
import AgentContact from './AgentContact';
interface AgentDetailContentProps {
agent: AgentWithSupport;
agent: t.Agent;
}
/**
@ -106,37 +98,6 @@ const AgentDetailContent: React.FC<AgentDetailContentProps> = ({ agent }) => {
});
};
/**
* Format contact information with mailto links when appropriate
*/
const formatContact = () => {
if (!agent?.support_contact) return null;
const { name, email } = agent.support_contact;
if (name && email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{name}
</a>
);
}
if (email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{email}
</a>
);
}
if (name) {
return <span>{name}</span>;
}
return null;
};
return (
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
{/* Agent avatar */}
@ -149,12 +110,7 @@ const AgentDetailContent: React.FC<AgentDetailContentProps> = ({ agent }) => {
</h2>
</div>
{/* Contact info */}
{agent?.support_contact && formatContact() && (
<div className="mt-1 text-center text-sm text-text-secondary">
{localize('com_agents_contact')}: {formatContact()}
</div>
)}
<AgentContact agent={agent} className="mt-1 justify-center text-center text-sm" />
{/* Agent description */}
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">

View file

@ -1,9 +1,25 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentCard from '../AgentCard';
import type t from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import AgentCard from '../AgentCard';
jest.mock('~/utils', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require('react');
return {
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
renderAgentAvatar: (agent: any) => {
const avatar = agent.avatar;
const src = typeof avatar === 'string' ? avatar : avatar?.filepath;
if (src) {
return <img src={src} alt={`${agent.name} avatar`} />;
}
return <svg className="lucide-feather" />;
},
};
});
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
@ -12,7 +28,8 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
com_ui_by_author: 'by {{0}}',
com_agents_contact: 'Contact',
com_agents_no_contact_available: 'No contact available',
com_agents_description_card: '{{description}}',
};
return mockTranslations[key] || key;
@ -26,7 +43,8 @@ jest.mock('~/hooks', () => ({
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
com_ui_by_author: 'by {{0}}',
com_agents_contact: 'Contact',
com_agents_no_contact_available: 'No contact available',
com_agents_description_card: '{{description}}',
};
let translation = mockTranslations[key] || key;
@ -73,46 +91,52 @@ jest.mock('~/Providers', () => ({
}));
// Mock @librechat/client with proper Dialog behavior
jest.mock('@librechat/client', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require('react');
return {
...jest.requireActual('@librechat/client'),
useToastContext: jest.fn(() => ({
showToast: jest.fn(),
})),
OGDialog: ({ children, open, onOpenChange }: any) => {
// Store onOpenChange in context for trigger to call
return (
<div data-testid="dialog-wrapper" data-open={open}>
{React.Children.map(children, (child: any) => {
if (child?.type?.displayName === 'OGDialogTrigger' || child?.props?.['data-trigger']) {
return React.cloneElement(child, { onOpenChange });
}
// Only render content when open
if (child?.type?.displayName === 'OGDialogContent' && !open) {
return null;
}
return child;
})}
</div>
);
},
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<any>, {
onClick: (e: any) => {
(children as any).props?.onClick?.(e);
onOpenChange?.(true);
},
});
}
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
},
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
};
});
jest.mock(
'@librechat/client',
() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require('react');
return {
useToastContext: jest.fn(() => ({
showToast: jest.fn(),
})),
OGDialog: ({ children, open, onOpenChange }: any) => {
// Store onOpenChange in context for trigger to call
return (
<div data-testid="dialog-wrapper" data-open={open}>
{React.Children.map(children, (child: any) => {
if (
child?.type?.displayName === 'OGDialogTrigger' ||
child?.props?.['data-trigger']
) {
return React.cloneElement(child, { onOpenChange });
}
// Only render content when open
if (child?.type?.displayName === 'OGDialogContent' && !open) {
return null;
}
return child;
})}
</div>
);
},
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<any>, {
onClick: (e: any) => {
(children as any).props?.onClick?.(e);
onOpenChange?.(true);
},
});
}
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
},
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
};
},
{ virtual: true },
);
// Create wrapper with QueryClient
const createWrapper = () => {
@ -290,13 +314,18 @@ describe('AgentCard', () => {
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.getByText('No contact available')).toBeInTheDocument();
});
it('displays authorName when support_contact is missing', () => {
it('falls back to owner contact when support_contact is missing', () => {
const agentWithAuthorName = {
...mockAgent,
support_contact: undefined,
authorName: 'John Doe',
owner_contact: {
name: 'Owner User',
email: 'owner@example.com',
},
};
render(
@ -305,7 +334,12 @@ describe('AgentCard', () => {
</Wrapper>,
);
expect(screen.getByText('by John Doe')).toBeInTheDocument();
expect(screen.getByText('Contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
expect(screen.queryByText('by John Doe')).not.toBeInTheDocument();
});
it('has proper accessibility attributes', () => {

View file

@ -0,0 +1,71 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentContact from '../AgentContact';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_agents_contact: 'Contact',
com_agents_no_contact_available: 'No contact available',
};
return translations[key] || key;
},
}));
jest.mock('~/utils', () => ({
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
}));
describe('AgentContact', () => {
it('uses support contact before owner contact', () => {
render(
<AgentContact
agent={
{
support_contact: { name: 'Support Team', email: 'support@example.com' },
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
} as any
}
/>,
);
expect(screen.getByText('Contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
'href',
'mailto:support@example.com',
);
expect(screen.queryByText('Owner User')).not.toBeInTheDocument();
});
it('falls back to owner contact', () => {
render(
<AgentContact
agent={
{
support_contact: undefined,
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
} as any
}
/>,
);
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
});
it('renders a plain name when no email is available', () => {
render(<AgentContact agent={{ owner_contact: { name: 'Owner User' } } as any} />);
expect(screen.getByText('Owner User')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders no-contact text when no contact is available', () => {
render(<AgentContact agent={{ support_contact: {}, owner_contact: undefined } as any} />);
expect(screen.getByText('No contact available')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,135 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@testing-library/jest-dom';
import AgentDetailContent from '../AgentDetailContent';
jest.mock('librechat-data-provider', () => ({
QueryKeys: {
agents: 'agents',
messages: 'messages',
},
Constants: {
NEW_CONVO: 'new',
},
EModelEndpoint: {
agents: 'agents',
},
PermissionBits: {
EDIT: 2,
},
LocalStorageKeys: {
AGENT_ID_PREFIX: 'agent:',
},
}));
jest.mock(
'@librechat/client',
() => ({
OGDialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
useToastContext: () => ({
showToast: jest.fn(),
}),
}),
{ virtual: true },
);
jest.mock('~/hooks', () => ({
useDefaultConvo: () => jest.fn((value) => value.conversation),
useFavorites: () => ({
isFavoriteAgent: jest.fn(() => false),
toggleFavoriteAgent: jest.fn(),
}),
useLocalize: () => (key: string, values?: Record<string, string>) => {
const translations: Record<string, string> = {
com_agents_contact: 'Contact',
com_agents_no_contact_available: 'No contact available',
com_agents_loading: 'Loading',
com_agents_link_copied: 'Link copied',
com_agents_link_copy_failed: 'Link copy failed',
com_agents_start_chat: 'Start chat',
com_agents_chat_with: `Chat with ${values?.name ?? ''}`,
com_ui_agent: 'Agent',
com_ui_pin: 'Pin',
com_ui_unpin: 'Unpin',
com_agents_copy_link: 'Copy link',
};
return translations[key] || key;
},
}));
jest.mock('~/Providers', () => ({
useChatContext: () => ({
conversation: undefined,
newConversation: jest.fn(),
}),
}));
jest.mock('~/utils', () => ({
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
clearMessagesCache: jest.fn(),
renderAgentAvatar: () => <div data-testid="agent-avatar" />,
}));
const renderWithClient = (children: React.ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>);
};
const baseAgent = {
id: 'agent-1',
name: 'Agent One',
description: 'Agent description',
provider: 'openai',
model: 'gpt-4',
model_parameters: {},
};
describe('AgentDetailContent', () => {
it('renders support contact with mailto link', () => {
renderWithClient(
<AgentDetailContent
agent={
{
...baseAgent,
support_contact: { name: 'Support Team', email: 'support@example.com' },
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
} as any
}
/>,
);
expect(screen.getByText('Contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
'href',
'mailto:support@example.com',
);
expect(screen.queryByText('Owner User')).not.toBeInTheDocument();
});
it('falls back to owner contact when support contact is missing', () => {
renderWithClient(
<AgentDetailContent
agent={
{
...baseAgent,
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
} as any
}
/>,
);
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
});
});

View file

@ -12,6 +12,7 @@ import {
} from '~/utils';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import AgentContact from '~/components/Agents/AgentContact';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { useLocalize, useAuthContext } from '~/hooks';
@ -88,6 +89,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
}),
[],
);
const selectedAgent =
isAgent && conversation?.agent_id != null ? agentsMap?.[conversation.agent_id] : undefined;
const getGreeting = useCallback(() => {
if (typeof startupConfig?.interface?.customWelcome === 'string') {
@ -135,7 +138,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
if (contentRef.current) {
setContentHeight(contentRef.current.offsetHeight);
}
}, [lineCount, description]);
}, [lineCount, description, selectedAgent]);
const getDynamicMargin = useMemo(() => {
let margin = 'mb-0';
@ -234,6 +237,12 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
{description}
</div>
))}
{selectedAgent && (
<AgentContact
agent={selectedAgent}
className="animate-fadeIn mt-2 max-w-md justify-center text-center text-sm"
/>
)}
</div>
</div>
);

View file

@ -0,0 +1,156 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Landing from '../Landing';
let mockConversation: Record<string, unknown> | null = null;
let mockAgentsMap: Record<string, any> | undefined;
let mockAssistantMap: Record<string, any> | undefined;
jest.mock('@react-spring/web', () => ({
easings: {
easeOutCubic: jest.fn(),
},
}));
jest.mock('librechat-data-provider', () => ({
EModelEndpoint: {
azureOpenAI: 'azureOpenAI',
openAI: 'openAI',
},
}));
jest.mock(
'@librechat/client',
() => ({
BirthdayIcon: () => <span data-testid="birthday-icon" />,
TooltipAnchor: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SplitText: ({ text }: { text: string }) => <span>{text}</span>,
}),
{ virtual: true },
);
jest.mock('~/Providers', () => ({
useChatContext: () => ({ conversation: mockConversation }),
useAgentsMapContext: () => mockAgentsMap,
useAssistantsMapContext: () => mockAssistantMap,
}));
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => ({ data: { interface: {} } }),
useGetEndpointsQuery: () => ({ data: {} }),
}));
jest.mock('~/hooks', () => ({
useAuthContext: () => ({ user: undefined }),
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_agents_contact: 'Contact',
com_agents_no_contact_available: 'No contact available',
com_ui_good_morning: 'Good morning',
com_ui_good_afternoon: 'Good afternoon',
com_ui_good_evening: 'Good evening',
com_ui_late_night: 'Good evening',
com_ui_weekend_morning: 'Good morning',
};
return translations[key] || key;
},
}));
jest.mock('~/utils', () => ({
CONFIG_HTML_MEDIA_ATTR: {},
CONFIG_HTML_MEDIA_TAGS: [],
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
createConfigHtmlSanitizer: () => (html: string) => html,
getIconEndpoint: ({ endpoint }: { endpoint: string }) => endpoint,
getModelSpec: () => undefined,
getEntity: ({
endpoint,
agentsMap,
assistantMap,
agent_id,
assistant_id,
}: {
endpoint: string;
agentsMap?: Record<string, any>;
assistantMap?: Record<string, any>;
agent_id?: string;
assistant_id?: string;
}) => {
if (endpoint === 'agents' && agent_id != null) {
return { entity: agentsMap?.[agent_id], isAgent: true, isAssistant: false };
}
if (assistant_id != null) {
return { entity: assistantMap?.[assistant_id], isAgent: false, isAssistant: true };
}
return { entity: undefined, isAgent: false, isAssistant: false };
},
}));
jest.mock('~/components/Endpoints/ConvoIcon', () => () => <span data-testid="convo-icon" />);
describe('Landing agent contact', () => {
beforeEach(() => {
mockConversation = null;
mockAgentsMap = undefined;
mockAssistantMap = undefined;
});
it('shows contact for the selected agent from agentsMap', () => {
mockConversation = {
endpoint: 'agents',
agent_id: 'agent-1',
};
mockAgentsMap = {
'agent-1': {
id: 'agent-1',
name: 'Portal Remote Agent',
description: 'Remote Agent Showcase',
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
},
};
render(<Landing centerFormOnLanding={false} />);
expect(screen.getByText('Portal Remote Agent')).toBeInTheDocument();
expect(screen.getByText('Remote Agent Showcase')).toBeInTheDocument();
expect(screen.getByText('Contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
});
it('does not show contact when the selected agent is missing from agentsMap', () => {
mockConversation = {
endpoint: 'agents',
agent_id: 'missing-agent',
greeting: 'Start chatting',
};
mockAgentsMap = {};
render(<Landing centerFormOnLanding={false} />);
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
expect(screen.queryByText('No contact available')).not.toBeInTheDocument();
});
it('does not show contact for assistants', () => {
mockConversation = {
endpoint: 'assistants',
assistant_id: 'assistant-1',
};
mockAssistantMap = {
'assistant-1': {
id: 'assistant-1',
name: 'Assistant',
description: 'Assistant description',
},
};
render(<Landing centerFormOnLanding={false} />);
expect(screen.getByText('Assistant')).toBeInTheDocument();
expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, PermissionBits, QueryKeys } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { QueryClient, UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
/**
* AGENTS
@ -10,6 +10,7 @@ export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [
{ requiredPermission: PermissionBits.VIEW },
{ requiredPermission: PermissionBits.EDIT },
];
/**
* Create a new agent
*/

View file

@ -34,6 +34,7 @@
"com_agents_chat_with": "Chat with {{name}}",
"com_agents_clear_search": "Clear search",
"com_agents_contact": "Contact",
"com_agents_no_contact_available": "No contact available",
"com_agents_copy_link": "Copy Link",
"com_agents_create_error": "There was an error creating your agent.",
"com_agents_created_by": "by",

View file

@ -0,0 +1,60 @@
import { resolveAgentOwnerContact } from './contact';
describe('resolveAgentOwnerContact', () => {
it('omits owner fallback when support contact has a name', () => {
const result = resolveAgentOwnerContact(
{ support_contact: { name: 'Support Team' } },
{ name: 'Agent Owner', email: 'owner@example.com' },
);
expect(result).toBeUndefined();
});
it('omits owner fallback when support contact has an email', () => {
const result = resolveAgentOwnerContact(
{ support_contact: { email: 'support@example.com' } },
{ name: 'Agent Owner', email: 'owner@example.com' },
);
expect(result).toBeUndefined();
});
it('uses owner name and email when support contact is empty', () => {
const result = resolveAgentOwnerContact(
{ support_contact: { name: ' ', email: '' } },
{ name: ' Agent Owner ', email: ' owner@example.com ' },
);
expect(result).toEqual({ name: 'Agent Owner', email: 'owner@example.com' });
});
it('falls back to username for owner display name', () => {
const result = resolveAgentOwnerContact({}, { username: 'owner.user' });
expect(result).toEqual({ name: 'owner.user' });
});
it('falls back to authorName when owner has no display name', () => {
const result = resolveAgentOwnerContact(
{ authorName: 'Legacy Author' },
{ email: 'owner@example.com' },
);
expect(result).toEqual({ name: 'Legacy Author', email: 'owner@example.com' });
});
it('omits owner contact when no owner can be resolved', () => {
const result = resolveAgentOwnerContact({ authorName: 'Legacy Author' }, null);
expect(result).toBeUndefined();
});
it('omits owner contact when all candidate fields are empty', () => {
const result = resolveAgentOwnerContact(
{ authorName: ' ' },
{ name: '', username: ' ', email: ' ' },
);
expect(result).toBeUndefined();
});
});

View file

@ -0,0 +1,55 @@
import type { AgentOwnerContact } from 'librechat-data-provider';
export interface AgentContactSource {
authorName?: string | null;
support_contact?: {
name?: string | null;
email?: string | null;
} | null;
}
export interface AgentOwnerContactSource {
name?: string | null;
username?: string | null;
email?: string | null;
}
const normalizeContactValue = (value?: string | null): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
export const hasSupportContact = (agent: AgentContactSource): boolean => {
const support = agent.support_contact;
if (!support) {
return false;
}
return !!normalizeContactValue(support.name) || !!normalizeContactValue(support.email);
};
export function resolveAgentOwnerContact(
agent: AgentContactSource,
owner: AgentOwnerContactSource | null,
): AgentOwnerContact | undefined {
if (hasSupportContact(agent) || owner == null) {
return undefined;
}
const name =
normalizeContactValue(owner.name) ??
normalizeContactValue(owner.username) ??
normalizeContactValue(agent.authorName);
const email = normalizeContactValue(owner.email);
if (!name && !email) {
return undefined;
}
return {
...(name ? { name } : {}),
...(email ? { email } : {}),
};
}

View file

@ -3,6 +3,7 @@ export * from './attachments';
export * from './chain';
export * from './client';
export * from './config';
export * from './contact';
export * from './context';
export * from './discovery';
export * from './edges';

View file

@ -209,6 +209,11 @@ export type SupportContact = {
email?: string;
};
export type AgentOwnerContact = {
name?: string;
email?: string;
};
/**
* Specifies who can invoke a tool.
* - 'direct': LLM can call directly
@ -286,6 +291,7 @@ export type Agent = {
version?: number;
category?: string;
support_contact?: SupportContact;
owner_contact?: AgentOwnerContact;
/** Per-tool configuration options (deferred loading, allowed callers, etc.) */
tool_options?: AgentToolOptions;
/** Optional allowlist of skill ObjectIds. Only applies when `skills_enabled`. */

View file

@ -1,5 +1,6 @@
export { default as actionSchema } from './action';
export { default as agentSchema } from './agent';
export { default as aclEntrySchema } from './aclEntry';
export { default as agentApiKeySchema } from './agentApiKey';
export { default as agentCategorySchema } from './agentCategory';
export { default as assistantSchema } from './assistant';