feat: Agent Version History and Management (#7455)

*  feat: Enhance agent update functionality to save current state in versions array

- Updated the `updateAgent` function to push the current agent's state into a new `versions` array when an agent is updated.
- Modified the agent schema to include a `versions` field for storing historical states of agents.

*  feat: Add comprehensive CRUD operations for agents in tests

- Introduced a new test suite for CRUD operations on agents, including create, read, update, and delete functionalities.
- Implemented tests for listing agents by author and updating agent projects.
- Enhanced the agent model to support version history tracking during updates.
- Ensured proper environment variable management during tests.

*  feat: Introduce version tracking for agents and enhance UI components

- Added a `version` property to the agent model to track the number of versions.
- Updated the `getAgentHandler` to include the agent's version in the response.
- Introduced a new `VersionButton` component for navigating to the version panel.
- Created a `VersionPanel` component for displaying version-related information.
- Updated the UI to conditionally render the version button and panel based on the active state.
- Added localization for the new version-related UI elements.

*  i18n: Add "version" translation key across multiple languages

- Introduced the "com_ui_agent_version" translation key in various language files to support version tracking for agents.
- Updated Arabic, Czech, German, English, Spanish, Estonian, Persian, Finnish, French, Hebrew, Hungarian, Indonesian, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil and Portugal), Russian, Swedish, Thai, Turkish, Vietnamese, and Chinese (Simplified and Traditional) translations.

*  feat: Update AgentFooter to conditionally render AdminSettings

- Modified the logic for displaying buttons in the AgentFooter component to only show them when the active panel is the builder.
- Ensured that AdminSettings is displayed only when the user has an admin role and the buttons are visible.

*  feat: Enhance AgentPanelSwitch and VersionPanel for improved agent capabilities

- Updated AgentPanelSwitch to include a new VersionPanel for displaying version-related information.
- Enhanced agentsConfig logic to properly handle agent capabilities.
- Modified VersionPanel to improve structure and localization support.
- Integrated createAgent mutation for future agent creation functionality.

*  feat: Enhance VersionPanel to display agent version history and loading states

- Integrated version fetching logic in VersionPanel to retrieve and display agent version history.
- Added loading and error handling states to improve user experience.
- Updated agent schema to use mixed types for versions, allowing for more flexible version data structures.
- Introduced localization support for version-related UI elements.

*  feat: Update VersionPanel and AgentPanelSwitch to enhance agent selection and version display

- Modified AgentPanelSwitch to pass selectedAgentId to VersionPanel for improved agent context.
- Enhanced VersionPanel to handle multiple timestamp formats and display appropriate messages when no agent is selected.
- Improved structure and readability of the VersionPanel component by adding a helper function for timestamp retrieval.

*  feat: Refactor VersionPanel to utilize localization and improve timestamp handling

- Replaced hardcoded text constants with localization support for various UI elements in VersionPanel.
- Enhanced the timestamp retrieval function to handle errors gracefully and utilize localized messages for unknown dates.
- Improved user feedback by displaying localized messages for agent selection, version errors, and empty states.

*  refactor: Clean up VersionPanel by removing unused code and improving timestamp handling

*  feat: Implement agent version reverting functionality

- Added `revertAgentVersion` method in the Agent model to allow reverting to a previous version of an agent.
- Introduced `revertAgentVersionHandler` in the agents controller to handle requests for reverting agent versions.
- Updated API routes to include a new endpoint for reverting agent versions.
- Enhanced the VersionPanel component to support version restoration with user confirmation and feedback.
- Added localization support for success and error messages related to version restoration.

*  i18n: Add localization for agent version restoration messages

* Simplify VersionPanel by removing unused parameters and enhancing agent ID handling

* Refactor Agent model and VersionPanel component to streamline version data handling

* Update version handling in Agent model and VersionPanel

- Enhanced the Agent model to include an `updatedAt` timestamp when pushing new versions.
- Improved the VersionPanel component to sort versions by the `updatedAt` timestamp for better display order.
- Added a new localization entry for indicating the active version of an agent.

*  i18n: Add localization for active agent version across multiple languages

*  feat: Introduce version management components for agent history

- Added `isActiveVersion` utility to determine the active version of an agent based on various criteria.
- Implemented `VersionContent` and `VersionItem` components to display agent version history, including loading and error states.
- Enhanced `VersionPanel` to integrate new components and manage version context effectively.
- Added comprehensive tests for version management functionalities to ensure reliability and correctness.

* Add unit tests for AgentFooter component

* cleanup

* Enhance agent version update handling and add unit tests for update operators

- Updated the `updateAgent` function to properly handle various update operators ($push, $pull, $addToSet) while maintaining version history.
- Modified unit tests to validate the correct behavior of agent updates, including versioning and tool management.

* Enhance version comparison logic and update tests for artifacts handling

- Modified the `isActiveVersion` utility to include artifacts in the version comparison criteria.
- Updated the `VersionPanel` component to support artifacts in the agent state.
- Added new unit tests to validate artifacts matching scenarios and edge cases in the `isActiveVersion` function.

* Implement duplicate version detection in agent updates and enhance error handling

- Added `isDuplicateVersion` function to check for identical versions during agent updates, excluding certain fields.
- Updated `updateAgent` function to throw an error if a duplicate version is detected, with detailed error information.
- Enhanced the `updateAgentHandler` to return appropriate responses for duplicate version errors.
- Modified client-side error handling to display user-friendly messages for duplicate version scenarios.
- Added comprehensive unit tests to validate duplicate version detection and error handling across various update scenarios.

* Update version title localization to include version number across multiple languages

- Modified the `com_ui_agent_version_title` translation key to include a placeholder for the version number in various language files.
- Enhanced the `VersionItem` component to utilize the updated localization for displaying version titles dynamically.

* Enhance agent version handling and add revert functionality

- Updated the `isDuplicateVersion` function to improve version comparison logic, including special handling for `projectIds` and arrays of objects.
- Modified the `updateAgent` function to streamline version updates and removed unnecessary checks for test environments.
- Introduced a new `revertAgentVersion` function to allow reverting agents to specific versions, with detailed documentation.
- Enhanced unit tests to validate duplicate version detection and revert functionality, ensuring robust error handling and version management.

* fix CI issues

* cleanup

* Revert all non-English translations

* clean up tests
This commit is contained in:
matt burnett 2025-05-20 15:03:13 -04:00 committed by Danny Avila
parent 5be446edff
commit d47d827ed9
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
26 changed files with 2362 additions and 16 deletions

View file

@ -142,6 +142,7 @@ export enum Panel {
builder = 'builder',
actions = 'actions',
model = 'model',
version = 'version',
}
export type FileSetter =

View file

@ -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">

View file

@ -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}` : ''

View file

@ -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} />
);

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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(),
);
});
});

View file

@ -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);
});
});
});

View file

@ -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()
);
};

View file

@ -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();
});
});
});

View file

@ -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);
},
},
);
};

View file

@ -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."
}
}