mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-19 08:46:11 +01:00
Merge branch 'dev' into feat/Multitenant-login-OIDC
This commit is contained in:
commit
edaa357e9d
26 changed files with 2362 additions and 16 deletions
|
|
@ -142,6 +142,7 @@ export enum Panel {
|
|||
builder = 'builder',
|
||||
actions = 'actions',
|
||||
model = 'model',
|
||||
version = 'version',
|
||||
}
|
||||
|
||||
export type FileSetter =
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { useWatch, useFormContext } from 'react-hook-form';
|
||||
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
|
|
@ -11,6 +10,7 @@ import DeleteButton from './DeleteButton';
|
|||
import { Spinner } from '~/components';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import { Panel } from '~/common';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
|
||||
export default function AgentFooter({
|
||||
activePanel,
|
||||
|
|
@ -55,6 +55,7 @@ export default function AgentFooter({
|
|||
return (
|
||||
<div className="mb-1 flex w-full flex-col gap-2">
|
||||
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{showButtons && agent_id && <VersionButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -87,7 +87,42 @@ export default function AgentPanel({
|
|||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
const error = err as Error & {
|
||||
statusCode?: number;
|
||||
details?: { duplicateVersion?: any; versionIndex?: number };
|
||||
response?: { status?: number; data?: any };
|
||||
};
|
||||
|
||||
const isDuplicateVersionError =
|
||||
(error.statusCode === 409 && error.details?.duplicateVersion) ||
|
||||
(error.response?.status === 409 && error.response?.data?.details?.duplicateVersion);
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
let versionIndex: number | undefined = undefined;
|
||||
|
||||
if (error.details?.versionIndex !== undefined) {
|
||||
versionIndex = error.details.versionIndex;
|
||||
} else if (error.response?.data?.details?.versionIndex !== undefined) {
|
||||
versionIndex = error.response.data.details.versionIndex;
|
||||
}
|
||||
|
||||
if (versionIndex === undefined || versionIndex < 0) {
|
||||
showToast({
|
||||
message: localize('com_agents_update_error'),
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_duplicate', { versionIndex: versionIndex + 1 }),
|
||||
status: 'error',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { ActionsEndpoint } from '~/common';
|
||||
import type { Action, TConfig, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery } from '~/data-provider';
|
||||
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import ActionsPanel from './ActionsPanel';
|
||||
import AgentPanel from './AgentPanel';
|
||||
import VersionPanel from './Version/VersionPanel';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
export default function AgentPanelSwitch() {
|
||||
|
|
@ -15,11 +16,19 @@ export default function AgentPanelSwitch() {
|
|||
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
|
||||
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const createMutation = useCreateAgentMutation();
|
||||
|
||||
const agentsConfig = useMemo(
|
||||
() => endpointsConfig?.[EModelEndpoint.agents] ?? ({} as TConfig | null),
|
||||
[endpointsConfig],
|
||||
);
|
||||
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
|
||||
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
...(config as TConfig),
|
||||
capabilities: Array.isArray(config.capabilities)
|
||||
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
|
||||
: ([] as AgentCapabilities[]),
|
||||
} as TAgentsEndpoint;
|
||||
}, [endpointsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const agent_id = conversation?.agent_id ?? '';
|
||||
|
|
@ -41,12 +50,23 @@ export default function AgentPanelSwitch() {
|
|||
setActivePanel,
|
||||
setCurrentAgentId,
|
||||
agent_id: currentAgentId,
|
||||
createMutation,
|
||||
};
|
||||
|
||||
if (activePanel === Panel.actions) {
|
||||
return <ActionsPanel {...commonProps} />;
|
||||
}
|
||||
|
||||
if (activePanel === Panel.version) {
|
||||
return (
|
||||
<VersionPanel
|
||||
setActivePanel={setActivePanel}
|
||||
agentsConfig={agentsConfig}
|
||||
selectedAgentId={currentAgentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { History } from 'lucide-react';
|
||||
import { Panel } from '~/common';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VersionButtonProps {
|
||||
setActivePanel: (panel: Panel) => void;
|
||||
}
|
||||
|
||||
const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
|
||||
onClick={() => setActivePanel(Panel.version)}
|
||||
>
|
||||
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_agent_version')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default VersionButton;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import VersionItem from './VersionItem';
|
||||
import { VersionContext } from './VersionPanel';
|
||||
|
||||
type VersionContentProps = {
|
||||
selectedAgentId: string;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
versionContext: VersionContext;
|
||||
onRestore: (index: number) => void;
|
||||
};
|
||||
|
||||
export default function VersionContent({
|
||||
selectedAgentId,
|
||||
isLoading,
|
||||
error,
|
||||
versionContext,
|
||||
onRestore,
|
||||
}: VersionContentProps) {
|
||||
const { versions, versionIds } = versionContext;
|
||||
const localize = useLocalize();
|
||||
|
||||
if (!selectedAgentId) {
|
||||
return (
|
||||
<div className="py-8 text-center text-text-secondary">
|
||||
{localize('com_ui_agent_version_no_agent')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center text-red-500">{localize('com_ui_agent_version_error')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versionIds.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{versionIds.map(({ id, version, isActive }) => (
|
||||
<VersionItem
|
||||
key={id}
|
||||
version={version}
|
||||
index={id}
|
||||
isActive={isActive}
|
||||
versionsLength={versions.length}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center text-text-secondary">
|
||||
{localize('com_ui_agent_version_empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { VersionRecord } from './VersionPanel';
|
||||
|
||||
type VersionItemProps = {
|
||||
version: VersionRecord;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
versionsLength: number;
|
||||
onRestore: (index: number) => void;
|
||||
};
|
||||
|
||||
export default function VersionItem({
|
||||
version,
|
||||
index,
|
||||
isActive,
|
||||
versionsLength,
|
||||
onRestore,
|
||||
}: VersionItemProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getVersionTimestamp = (version: VersionRecord): string => {
|
||||
const timestamp = version.updatedAt || version.createdAt;
|
||||
|
||||
if (timestamp) {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime()) || date.toString() === 'Invalid Date') {
|
||||
return localize('com_ui_agent_version_unknown_date');
|
||||
}
|
||||
return date.toLocaleString();
|
||||
} catch (error) {
|
||||
return localize('com_ui_agent_version_unknown_date');
|
||||
}
|
||||
}
|
||||
|
||||
return localize('com_ui_agent_version_no_date');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border-light p-3">
|
||||
<div className="flex items-center justify-between font-medium">
|
||||
<span>
|
||||
{localize('com_ui_agent_version_title', { versionNumber: versionsLength - index })}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full border border-green-600 bg-green-600/20 px-2 py-0.5 text-xs font-medium text-green-700 dark:border-green-500 dark:bg-green-500/30 dark:text-green-300">
|
||||
{localize('com_ui_agent_version_active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">{getVersionTimestamp(version)}</div>
|
||||
{!isActive && (
|
||||
<button
|
||||
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={() => {
|
||||
if (window.confirm(localize('com_ui_agent_version_restore_confirm'))) {
|
||||
onRestore(index);
|
||||
}
|
||||
}}
|
||||
aria-label={localize('com_ui_agent_version_restore')}
|
||||
>
|
||||
{localize('com_ui_agent_version_restore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
client/src/components/SidePanel/Agents/Version/VersionPanel.tsx
Normal file
189
client/src/components/SidePanel/Agents/Version/VersionPanel.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type { Agent, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { AgentPanelProps } from '~/common';
|
||||
import { Panel } from '~/common';
|
||||
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
|
||||
import { useLocalize, useToast } from '~/hooks';
|
||||
import VersionContent from './VersionContent';
|
||||
import { isActiveVersion } from './isActiveVersion';
|
||||
|
||||
export type VersionRecord = Record<string, any>;
|
||||
|
||||
export type AgentState = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
artifacts?: string | null;
|
||||
capabilities?: string[];
|
||||
tools?: string[];
|
||||
} | null;
|
||||
|
||||
export type VersionWithId = {
|
||||
id: number;
|
||||
originalIndex: number;
|
||||
version: VersionRecord;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type VersionContext = {
|
||||
versions: VersionRecord[];
|
||||
versionIds: VersionWithId[];
|
||||
currentAgent: AgentState;
|
||||
selectedAgentId: string;
|
||||
activeVersion: VersionRecord | null;
|
||||
};
|
||||
|
||||
export interface AgentWithVersions extends Agent {
|
||||
capabilities?: string[];
|
||||
versions?: Array<VersionRecord>;
|
||||
}
|
||||
|
||||
export type VersionPanelProps = {
|
||||
agentsConfig: TAgentsEndpoint | null;
|
||||
setActivePanel: AgentPanelProps['setActivePanel'];
|
||||
selectedAgentId?: string;
|
||||
};
|
||||
|
||||
export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToast();
|
||||
const {
|
||||
data: agent,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetAgentByIdQuery(selectedAgentId, {
|
||||
enabled: !!selectedAgentId && selectedAgentId !== '',
|
||||
});
|
||||
|
||||
const revertAgentVersion = useRevertAgentVersionMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_restore_success'),
|
||||
status: 'success',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_agent_version_restore_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const agentWithVersions = agent as AgentWithVersions;
|
||||
|
||||
const currentAgent = useMemo(() => {
|
||||
if (!agentWithVersions) return null;
|
||||
return {
|
||||
name: agentWithVersions.name,
|
||||
description: agentWithVersions.description,
|
||||
instructions: agentWithVersions.instructions,
|
||||
artifacts: agentWithVersions.artifacts,
|
||||
capabilities: agentWithVersions.capabilities,
|
||||
tools: agentWithVersions.tools,
|
||||
};
|
||||
}, [agentWithVersions]);
|
||||
|
||||
const versions = useMemo(() => {
|
||||
const versionsCopy = [...(agentWithVersions?.versions || [])];
|
||||
return versionsCopy.sort((a, b) => {
|
||||
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [agentWithVersions?.versions]);
|
||||
|
||||
const activeVersion = useMemo(() => {
|
||||
return versions.length > 0
|
||||
? versions.find((v) => isActiveVersion(v, currentAgent, versions)) || null
|
||||
: null;
|
||||
}, [versions, currentAgent]);
|
||||
|
||||
const versionIds = useMemo(() => {
|
||||
if (versions.length === 0) return [];
|
||||
|
||||
const matchingVersions = versions.filter((v) => isActiveVersion(v, currentAgent, versions));
|
||||
|
||||
const activeVersionId =
|
||||
matchingVersions.length > 0 ? versions.findIndex((v) => v === matchingVersions[0]) : -1;
|
||||
|
||||
return versions.map((version, displayIndex) => {
|
||||
const originalIndex =
|
||||
agentWithVersions?.versions?.findIndex(
|
||||
(v) =>
|
||||
v.updatedAt === version.updatedAt &&
|
||||
v.createdAt === version.createdAt &&
|
||||
v.name === version.name,
|
||||
) ?? displayIndex;
|
||||
|
||||
return {
|
||||
id: displayIndex,
|
||||
originalIndex,
|
||||
version,
|
||||
isActive: displayIndex === activeVersionId,
|
||||
};
|
||||
});
|
||||
}, [versions, currentAgent, agentWithVersions?.versions]);
|
||||
|
||||
const versionContext: VersionContext = useMemo(
|
||||
() => ({
|
||||
versions,
|
||||
versionIds,
|
||||
currentAgent,
|
||||
selectedAgentId,
|
||||
activeVersion,
|
||||
}),
|
||||
[versions, versionIds, currentAgent, selectedAgentId, activeVersion],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
(displayIndex: number) => {
|
||||
const versionWithId = versionIds.find((v) => v.id === displayIndex);
|
||||
|
||||
if (versionWithId) {
|
||||
const originalIndex = versionWithId.originalIndex;
|
||||
|
||||
revertAgentVersion.mutate({
|
||||
agent_id: selectedAgentId,
|
||||
version_index: originalIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[revertAgentVersion, selectedAgentId, versionIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-full min-h-[40vh] overflow-auto pb-12 text-sm">
|
||||
<div className="version-panel relative flex flex-col items-center px-16 py-4 text-center">
|
||||
<div className="absolute left-0 top-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
onClick={() => {
|
||||
setActivePanel(Panel.builder);
|
||||
}}
|
||||
>
|
||||
<div className="version-panel-content flex w-full items-center justify-center gap-2">
|
||||
<ChevronLeft />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 mt-2 text-xl font-medium">
|
||||
{localize('com_ui_agent_version_history')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-2">
|
||||
<VersionContent
|
||||
selectedAgentId={selectedAgentId}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
versionContext={versionContext}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import VersionContent from '../VersionContent';
|
||||
import { VersionContext } from '../VersionPanel';
|
||||
|
||||
const mockRestore = 'Restore';
|
||||
|
||||
jest.mock('../VersionItem', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(({ version, isActive, onRestore, index }) => (
|
||||
<div data-testid="version-item">
|
||||
<div>{version.name}</div>
|
||||
{!isActive && (
|
||||
<button data-testid={`restore-button-${index}`} onClick={() => onRestore(index)}>
|
||||
{mockRestore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key) => {
|
||||
const translations = {
|
||||
com_ui_agent_version_no_agent: 'No agent selected',
|
||||
com_ui_agent_version_error: 'Error loading versions',
|
||||
com_ui_agent_version_empty: 'No versions available',
|
||||
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||
com_ui_agent_version_restore: 'Restore',
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/svg', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
const mockVersionItem = jest.requireMock('../VersionItem').default;
|
||||
|
||||
describe('VersionContent', () => {
|
||||
const mockVersionIds = [
|
||||
{ id: 0, version: { name: 'First' }, isActive: true, originalIndex: 2 },
|
||||
{ id: 1, version: { name: 'Second' }, isActive: false, originalIndex: 1 },
|
||||
{ id: 2, version: { name: 'Third' }, isActive: false, originalIndex: 0 },
|
||||
];
|
||||
|
||||
const mockContext: VersionContext = {
|
||||
versions: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }],
|
||||
versionIds: mockVersionIds,
|
||||
currentAgent: { name: 'Test Agent', description: null, instructions: null },
|
||||
selectedAgentId: 'agent-123',
|
||||
activeVersion: { name: 'First' },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
selectedAgentId: 'agent-123',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
versionContext: mockContext,
|
||||
onRestore: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.confirm = jest.fn(() => true);
|
||||
});
|
||||
|
||||
test('renders different UI states correctly', () => {
|
||||
const renderTest = (props) => {
|
||||
const result = render(<VersionContent {...defaultProps} {...props} />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const { getByTestId, unmount: unmount1 } = renderTest({ isLoading: true });
|
||||
expect(getByTestId('spinner')).toBeInTheDocument();
|
||||
unmount1();
|
||||
|
||||
const { getByText: getText1, unmount: unmount2 } = renderTest({
|
||||
error: new Error('Test error'),
|
||||
});
|
||||
expect(getText1('Error loading versions')).toBeInTheDocument();
|
||||
unmount2();
|
||||
|
||||
const { getByText: getText2, unmount: unmount3 } = renderTest({ selectedAgentId: '' });
|
||||
expect(getText2('No agent selected')).toBeInTheDocument();
|
||||
unmount3();
|
||||
|
||||
const emptyContext = { ...mockContext, versions: [], versionIds: [] };
|
||||
const { getByText: getText3, unmount: unmount4 } = renderTest({ versionContext: emptyContext });
|
||||
expect(getText3('No versions available')).toBeInTheDocument();
|
||||
unmount4();
|
||||
|
||||
mockVersionItem.mockClear();
|
||||
|
||||
const { getAllByTestId } = renderTest({});
|
||||
expect(getAllByTestId('version-item')).toHaveLength(3);
|
||||
expect(mockVersionItem).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('restore functionality works correctly', () => {
|
||||
const onRestoreMock = jest.fn();
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<VersionContent {...defaultProps} onRestore={onRestoreMock} />,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('restore-button-1'));
|
||||
expect(onRestoreMock).toHaveBeenCalledWith(1);
|
||||
|
||||
expect(queryByTestId('restore-button-0')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('restore-button-1')).toBeInTheDocument();
|
||||
expect(queryByTestId('restore-button-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles edge cases in data', () => {
|
||||
const { getAllByTestId, getByText, queryByTestId, queryByText, rerender } = render(
|
||||
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versions: [] }} />,
|
||||
);
|
||||
expect(getAllByTestId('version-item')).toHaveLength(mockVersionIds.length);
|
||||
|
||||
rerender(
|
||||
<VersionContent {...defaultProps} versionContext={{ ...mockContext, versionIds: [] }} />,
|
||||
);
|
||||
expect(getByText('No versions available')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<VersionContent
|
||||
{...defaultProps}
|
||||
selectedAgentId=""
|
||||
isLoading={true}
|
||||
error={new Error('Test')}
|
||||
/>,
|
||||
);
|
||||
expect(getByText('No agent selected')).toBeInTheDocument();
|
||||
expect(queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<VersionContent {...defaultProps} isLoading={true} error={new Error('Test')} />);
|
||||
expect(queryByTestId('spinner')).toBeInTheDocument();
|
||||
expect(queryByText('Error loading versions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import VersionItem from '../VersionItem';
|
||||
import { VersionRecord } from '../VersionPanel';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key, params) => {
|
||||
const translations = {
|
||||
com_ui_agent_version_title: params?.versionNumber
|
||||
? `Version ${params.versionNumber}`
|
||||
: 'Version',
|
||||
com_ui_agent_version_active: 'Active Version',
|
||||
com_ui_agent_version_restore: 'Restore',
|
||||
com_ui_agent_version_restore_confirm: 'Are you sure you want to restore this version?',
|
||||
com_ui_agent_version_unknown_date: 'Unknown date',
|
||||
com_ui_agent_version_no_date: 'No date',
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('VersionItem', () => {
|
||||
const mockVersion: VersionRecord = {
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
version: mockVersion,
|
||||
index: 1,
|
||||
isActive: false,
|
||||
versionsLength: 3,
|
||||
onRestore: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.confirm = jest.fn().mockImplementation(() => true);
|
||||
});
|
||||
|
||||
test('renders version number and timestamp', () => {
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
expect(screen.getByText('Version 2')).toBeInTheDocument();
|
||||
const date = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(date)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('active version badge and no restore button when active', () => {
|
||||
render(<VersionItem {...defaultProps} isActive={true} />);
|
||||
expect(screen.getByText('Active Version')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restore button and no active badge when not active', () => {
|
||||
render(<VersionItem {...defaultProps} isActive={false} />);
|
||||
expect(screen.queryByText('Active Version')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Restore')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restore confirmation flow - confirmed', () => {
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to restore this version?');
|
||||
expect(defaultProps.onRestore).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test('restore confirmation flow - canceled', () => {
|
||||
window.confirm = jest.fn().mockImplementation(() => false);
|
||||
render(<VersionItem {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Restore'));
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(defaultProps.onRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles invalid timestamp', () => {
|
||||
render(
|
||||
<VersionItem {...defaultProps} version={{ ...mockVersion, updatedAt: 'invalid-date' }} />,
|
||||
);
|
||||
expect(screen.getByText('Unknown date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles missing timestamps', () => {
|
||||
render(
|
||||
<VersionItem
|
||||
{...defaultProps}
|
||||
version={{ ...mockVersion, updatedAt: undefined, createdAt: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('prefers updatedAt over createdAt when both exist', () => {
|
||||
const versionWithBothDates = {
|
||||
...mockVersion,
|
||||
updatedAt: '2023-01-02T00:00:00Z',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
};
|
||||
render(<VersionItem {...defaultProps} version={versionWithBothDates} />);
|
||||
const updatedDate = new Date('2023-01-02T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(updatedDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('falls back to createdAt when updatedAt is missing', () => {
|
||||
render(
|
||||
<VersionItem
|
||||
{...defaultProps}
|
||||
version={{
|
||||
...mockVersion,
|
||||
updatedAt: undefined,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const createdDate = new Date('2023-01-01T00:00:00Z').toLocaleString();
|
||||
expect(screen.getByText(createdDate)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles empty version object', () => {
|
||||
render(<VersionItem {...defaultProps} version={{}} />);
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Panel } from '~/common/types';
|
||||
import VersionContent from '../VersionContent';
|
||||
import VersionPanel from '../VersionPanel';
|
||||
|
||||
const mockAgentData = {
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
versions: [
|
||||
{
|
||||
name: 'Version 1',
|
||||
description: 'Description 1',
|
||||
instructions: 'Instructions 1',
|
||||
tools: ['tool1'],
|
||||
capabilities: ['capability1'],
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'Version 2',
|
||||
description: 'Description 2',
|
||||
instructions: 'Instructions 2',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
createdAt: '2023-01-02T00:00:00Z',
|
||||
updatedAt: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetAgentByIdQuery: jest.fn(() => ({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
})),
|
||||
useRevertAgentVersionMutation: jest.fn(() => ({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../VersionContent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="version-content" />),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn().mockImplementation(() => (key) => key),
|
||||
useToast: jest.fn(() => ({ showToast: jest.fn() })),
|
||||
}));
|
||||
|
||||
describe('VersionPanel', () => {
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const defaultProps = {
|
||||
agentsConfig: null,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
selectedAgentId: 'agent-123',
|
||||
};
|
||||
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('renders panel UI and handles navigation', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-content')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(mockSetActivePanel).toHaveBeenCalledWith(Panel.builder);
|
||||
});
|
||||
|
||||
test('VersionContent receives correct props', () => {
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selectedAgentId: 'agent-123',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
versionContext: expect.objectContaining({
|
||||
currentAgent: expect.any(Object),
|
||||
versions: expect.any(Array),
|
||||
versionIds: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles data state variations', () => {
|
||||
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selectedAgentId: '' }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
versions: [],
|
||||
versionIds: [],
|
||||
currentAgent: null,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: { ...mockAgentData, versions: undefined },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({ versions: [] }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isLoading: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const testError = new Error('Test error');
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: testError,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: testError }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test('memoizes agent data correctly', () => {
|
||||
mockUseGetAgentByIdQuery.mockReturnValueOnce({
|
||||
data: mockAgentData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<VersionPanel {...defaultProps} />);
|
||||
expect(VersionContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
versionContext: expect.objectContaining({
|
||||
currentAgent: expect.objectContaining({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
}),
|
||||
versions: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Version 2' }),
|
||||
expect.objectContaining({ name: 'Version 1' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { isActiveVersion } from '../isActiveVersion';
|
||||
import type { AgentState, VersionRecord } from '../VersionPanel';
|
||||
|
||||
describe('isActiveVersion', () => {
|
||||
const createVersion = (overrides = {}): VersionRecord => ({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
artifacts: 'default',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAgentState = (overrides = {}): AgentState => ({
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
artifacts: 'default',
|
||||
tools: ['tool1', 'tool2'],
|
||||
capabilities: ['capability1', 'capability2'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('returns true for the first version in versions array when currentAgent is null', () => {
|
||||
const versions = [
|
||||
createVersion({ name: 'First Version' }),
|
||||
createVersion({ name: 'Second Version' }),
|
||||
];
|
||||
|
||||
expect(isActiveVersion(versions[0], null, versions)).toBe(true);
|
||||
expect(isActiveVersion(versions[1], null, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when all fields match exactly', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when names do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ name: 'Different Name' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when descriptions do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ description: 'Different Description' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when instructions do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ instructions: 'Different Instructions' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when artifacts do not match', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ artifacts: 'different_artifacts' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches tools regardless of order', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when tools arrays have different lengths', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'tool2', 'tool3'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when tools do not match', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'different'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches capabilities regardless of order', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability2', 'capability1'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when capabilities arrays have different lengths', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({
|
||||
capabilities: ['capability1', 'capability2', 'capability3'],
|
||||
});
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when capabilities do not match', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability1', 'different'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('handles missing tools arrays', () => {
|
||||
const version = createVersion({ tools: undefined });
|
||||
const currentAgent = createAgentState({ tools: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has tools but agent does not', () => {
|
||||
const version = createVersion({ tools: ['tool1', 'tool2'] });
|
||||
const currentAgent = createAgentState({ tools: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has tools but version does not', () => {
|
||||
const version = createVersion({ tools: undefined });
|
||||
const currentAgent = createAgentState({ tools: ['tool1', 'tool2'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles missing capabilities arrays', () => {
|
||||
const version = createVersion({ capabilities: undefined });
|
||||
const currentAgent = createAgentState({ capabilities: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has capabilities but agent does not', () => {
|
||||
const version = createVersion({ capabilities: ['capability1', 'capability2'] });
|
||||
const currentAgent = createAgentState({ capabilities: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has capabilities but version does not', () => {
|
||||
const version = createVersion({ capabilities: undefined });
|
||||
const currentAgent = createAgentState({ capabilities: ['capability1', 'capability2'] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles null values in fields', () => {
|
||||
const version = createVersion({ name: null });
|
||||
const currentAgent = createAgentState({ name: null });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles empty versions array', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles empty arrays for tools', () => {
|
||||
const version = createVersion({ tools: [] });
|
||||
const currentAgent = createAgentState({ tools: [] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles empty arrays for capabilities', () => {
|
||||
const version = createVersion({ capabilities: [] });
|
||||
const currentAgent = createAgentState({ capabilities: [] });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles missing artifacts field', () => {
|
||||
const version = createVersion({ artifacts: undefined });
|
||||
const currentAgent = createAgentState({ artifacts: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
|
||||
test('handles when version has artifacts but agent does not', () => {
|
||||
const version = createVersion();
|
||||
const currentAgent = createAgentState({ artifacts: undefined });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles when agent has artifacts but version does not', () => {
|
||||
const version = createVersion({ artifacts: undefined });
|
||||
const currentAgent = createAgentState();
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
|
||||
});
|
||||
|
||||
test('handles empty string for artifacts', () => {
|
||||
const version = createVersion({ artifacts: '' });
|
||||
const currentAgent = createAgentState({ artifacts: '' });
|
||||
const versions = [version];
|
||||
|
||||
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { AgentState, VersionRecord } from './VersionPanel';
|
||||
|
||||
export const isActiveVersion = (
|
||||
version: VersionRecord,
|
||||
currentAgent: AgentState,
|
||||
versions: VersionRecord[],
|
||||
): boolean => {
|
||||
if (!versions || versions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentAgent) {
|
||||
const versionIndex = versions.findIndex(
|
||||
(v) =>
|
||||
v.name === version.name &&
|
||||
v.instructions === version.instructions &&
|
||||
v.artifacts === version.artifacts,
|
||||
);
|
||||
return versionIndex === 0;
|
||||
}
|
||||
|
||||
const matchesName = version.name === currentAgent.name;
|
||||
const matchesDescription = version.description === currentAgent.description;
|
||||
const matchesInstructions = version.instructions === currentAgent.instructions;
|
||||
const matchesArtifacts = version.artifacts === currentAgent.artifacts;
|
||||
|
||||
const toolsMatch = () => {
|
||||
if (!version.tools && !currentAgent.tools) return true;
|
||||
if (!version.tools || !currentAgent.tools) return false;
|
||||
if (version.tools.length !== currentAgent.tools.length) return false;
|
||||
|
||||
const sortedVersionTools = [...version.tools].sort();
|
||||
const sortedCurrentTools = [...currentAgent.tools].sort();
|
||||
|
||||
return sortedVersionTools.every((tool, i) => tool === sortedCurrentTools[i]);
|
||||
};
|
||||
|
||||
const capabilitiesMatch = () => {
|
||||
if (!version.capabilities && !currentAgent.capabilities) return true;
|
||||
if (!version.capabilities || !currentAgent.capabilities) return false;
|
||||
if (version.capabilities.length !== currentAgent.capabilities.length) return false;
|
||||
|
||||
const sortedVersionCapabilities = [...version.capabilities].sort();
|
||||
const sortedCurrentCapabilities = [...currentAgent.capabilities].sort();
|
||||
|
||||
return sortedVersionCapabilities.every(
|
||||
(capability, i) => capability === sortedCurrentCapabilities[i],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
matchesName &&
|
||||
matchesDescription &&
|
||||
matchesInstructions &&
|
||||
matchesArtifacts &&
|
||||
toolsMatch() &&
|
||||
capabilitiesMatch()
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import AgentFooter from '../AgentFooter';
|
||||
import { Panel } from '~/common';
|
||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import * as reactHookForm from 'react-hook-form';
|
||||
import * as hooks from '~/hooks';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({
|
||||
control: {},
|
||||
}),
|
||||
useWatch: () => {
|
||||
return {
|
||||
agent: {
|
||||
name: 'Test Agent',
|
||||
author: 'user-123',
|
||||
projectIds: ['project-1'],
|
||||
isCollaborative: false,
|
||||
},
|
||||
id: 'agent-123',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: '',
|
||||
role: 'USER',
|
||||
provider: 'local',
|
||||
emailVerified: true,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
} as TUser;
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key) => {
|
||||
const translations = {
|
||||
com_ui_save: 'Save',
|
||||
com_ui_create: 'Create',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
useAuthContext: () => ({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
}),
|
||||
useHasAccess: () => true,
|
||||
}));
|
||||
|
||||
const createBaseMutation = <T = Agent, P = any>(
|
||||
isLoading = false,
|
||||
): UseMutationResult<T, Error, P> => {
|
||||
if (isLoading) {
|
||||
return {
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: false as const,
|
||||
status: 'loading' as const,
|
||||
error: null,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
reset: jest.fn(),
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
isPaused: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn().mockResolvedValue({} as T),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
isIdle: true as const,
|
||||
status: 'idle' as const,
|
||||
error: null,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
reset: jest.fn(),
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
isPaused: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useUpdateAgentMutation: () => createBaseMutation<Agent, any>(),
|
||||
}));
|
||||
|
||||
jest.mock('../Advanced/AdvancedButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="advanced-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../Version/VersionButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="version-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../AdminSettings', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="admin-settings" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DeleteButton', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="delete-button" />),
|
||||
}));
|
||||
|
||||
jest.mock('../ShareAgent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="share-agent" />),
|
||||
}));
|
||||
|
||||
jest.mock('../DuplicateAgent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div data-testid="duplicate-agent" />),
|
||||
}));
|
||||
|
||||
jest.mock('~/components', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
describe('AgentFooter', () => {
|
||||
const mockUsers = {
|
||||
regular: mockUser,
|
||||
admin: {
|
||||
...mockUser,
|
||||
id: 'admin-123',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: SystemRoles.ADMIN,
|
||||
} as TUser,
|
||||
different: {
|
||||
...mockUser,
|
||||
id: 'different-user',
|
||||
username: 'different',
|
||||
email: 'different@example.com',
|
||||
name: 'Different User',
|
||||
} as TUser,
|
||||
};
|
||||
|
||||
const createAuthContext = (user: TUser) => ({
|
||||
user,
|
||||
token: 'mock-token',
|
||||
isAuthenticated: true,
|
||||
error: undefined,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
roles: {},
|
||||
});
|
||||
|
||||
const mockSetActivePanel = jest.fn();
|
||||
const mockSetCurrentAgentId = jest.fn();
|
||||
const mockCreateMutation = createBaseMutation<Agent, AgentCreateParams>();
|
||||
const mockUpdateMutation = createBaseMutation<Agent, any>();
|
||||
|
||||
const defaultProps = {
|
||||
activePanel: Panel.builder,
|
||||
createMutation: mockCreateMutation,
|
||||
updateMutation: mockUpdateMutation,
|
||||
setActivePanel: mockSetActivePanel,
|
||||
setCurrentAgentId: mockSetCurrentAgentId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Main Functionality', () => {
|
||||
test('renders with standard components based on default state', () => {
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('advanced-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles loading states for createMutation', () => {
|
||||
const { unmount } = render(
|
||||
<AgentFooter {...defaultProps} createMutation={createBaseMutation(true)} />,
|
||||
);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('handles loading states for updateMutation', () => {
|
||||
render(<AgentFooter {...defaultProps} updateMutation={createBaseMutation(true)} />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
test('adjusts UI based on activePanel state', () => {
|
||||
render(<AgentFooter {...defaultProps} activePanel={Panel.advanced} />);
|
||||
expect(screen.queryByTestId('advanced-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('version-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on agent ID existence', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: { name: 'Test Agent', author: 'user-123' },
|
||||
id: undefined,
|
||||
}));
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('version-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on user role', () => {
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.admin));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('admin-settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(hooks, 'useAuthContext').mockReturnValue(createAuthContext(mockUsers.different));
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adjusts UI based on permissions', () => {
|
||||
jest.spyOn(hooks, 'useHasAccess').mockReturnValue(false);
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.queryByTestId('share-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('handles null agent data', () => {
|
||||
jest.spyOn(reactHookForm, 'useWatch').mockImplementation(() => ({
|
||||
agent: null,
|
||||
id: 'agent-123',
|
||||
}));
|
||||
|
||||
render(<AgentFooter {...defaultProps} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -43,7 +43,11 @@ export const useCreateAgentMutation = (
|
|||
*/
|
||||
export const useUpdateAgentMutation = (
|
||||
options?: t.UpdateAgentMutationOptions,
|
||||
): UseMutationResult<t.Agent, Error, { agent_id: string; data: t.AgentUpdateParams }> => {
|
||||
): UseMutationResult<
|
||||
t.Agent,
|
||||
t.DuplicateVersionError,
|
||||
{ agent_id: string; data: t.AgentUpdateParams }
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({ agent_id, data }: { agent_id: string; data: t.AgentUpdateParams }) => {
|
||||
|
|
@ -54,7 +58,10 @@ export const useUpdateAgentMutation = (
|
|||
},
|
||||
{
|
||||
onMutate: (variables) => options?.onMutate?.(variables),
|
||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||
onError: (error, variables, context) => {
|
||||
const typedError = error as t.DuplicateVersionError;
|
||||
return options?.onError?.(typedError, variables, context);
|
||||
},
|
||||
onSuccess: (updatedAgent, variables, context) => {
|
||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
||||
QueryKeys.agents,
|
||||
|
|
@ -170,7 +177,6 @@ export const useUploadAgentAvatarMutation = (
|
|||
unknown // context
|
||||
> => {
|
||||
return useMutation([MutationKeys.agentAvatarUpload], {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
mutationFn: ({ postCreation, ...variables }: t.AgentAvatarVariables) =>
|
||||
dataService.uploadAgentAvatar(variables),
|
||||
...(options || {}),
|
||||
|
|
@ -300,3 +306,46 @@ export const useDeleteAgentAction = (
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for reverting an agent to a previous version
|
||||
*/
|
||||
export const useRevertAgentVersionMutation = (
|
||||
options?: t.RevertAgentVersionOptions,
|
||||
): UseMutationResult<t.Agent, Error, { agent_id: string; version_index: number }> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({ agent_id, version_index }: { agent_id: string; version_index: number }) => {
|
||||
return dataService.revertAgentVersion({
|
||||
agent_id,
|
||||
version_index,
|
||||
});
|
||||
},
|
||||
{
|
||||
onMutate: (variables) => options?.onMutate?.(variables),
|
||||
onError: (error, variables, context) => options?.onError?.(error, variables, context),
|
||||
onSuccess: (revertedAgent, variables, context) => {
|
||||
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
|
||||
|
||||
const listRes = queryClient.getQueryData<t.AgentListResponse>([
|
||||
QueryKeys.agents,
|
||||
defaultOrderQuery,
|
||||
]);
|
||||
|
||||
if (listRes) {
|
||||
queryClient.setQueryData<t.AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
|
||||
...listRes,
|
||||
data: listRes.data.map((agent) => {
|
||||
if (agent.id === variables.agent_id) {
|
||||
return revertedAgent;
|
||||
}
|
||||
return agent;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return options?.onSuccess?.(revertedAgent, variables, context);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -483,6 +483,20 @@
|
|||
"com_ui_agent_recursion_limit_info": "Limits how many steps the agent can take in a run before giving a final response. Default is 25 steps. A step is either an AI API request or a tool usage round. For example, a basic tool interaction takes 3 steps: initial request, tool usage, and follow-up request.",
|
||||
"com_ui_agent_shared_to_all": "something needs to go here. was empty",
|
||||
"com_ui_agent_var": "{{0}} agent",
|
||||
"com_ui_agent_version": "Version",
|
||||
"com_ui_agent_version_history": "Version History",
|
||||
"com_ui_agent_version_error": "Error fetching versions",
|
||||
"com_ui_agent_version_empty": "No versions available",
|
||||
"com_ui_agent_version_title": "Version {{versionNumber}}",
|
||||
"com_ui_agent_version_restore": "Restore",
|
||||
"com_ui_agent_version_restore_confirm": "Are you sure you want to restore this version?",
|
||||
"com_ui_agent_version_restore_success": "Version restored successfully",
|
||||
"com_ui_agent_version_restore_error": "Failed to restore version",
|
||||
"com_ui_agent_version_no_agent": "No agent selected. Please select an agent to view version history.",
|
||||
"com_ui_agent_version_unknown_date": "Unknown date",
|
||||
"com_ui_agent_version_no_date": "Date not available",
|
||||
"com_ui_agent_version_active": "Active Version",
|
||||
"com_ui_agent_version_duplicate": "Duplicate version detected. This would create a version identical to Version {{versionIndex}}.",
|
||||
"com_ui_agents": "Agents",
|
||||
"com_ui_agents_allow_create": "Allow creating Agents",
|
||||
"com_ui_agents_allow_share_global": "Allow sharing Agents to all users",
|
||||
|
|
@ -882,4 +896,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "You",
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue