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

@ -21,7 +21,19 @@ const Agent = mongoose.model('agent', agentSchema);
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
return (await Agent.create(agentData)).toObject();
const { versions, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
versions: [
{
...versionData,
createdAt: timestamp,
updatedAt: timestamp,
},
],
};
return (await Agent.create(initialAgentData)).toObject();
};
/**
@ -103,6 +115,8 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
return null;
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.author.toString() === req.user.id) {
return agent;
}
@ -127,18 +141,146 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
}
};
/**
* Check if a version already exists in the versions array, excluding timestamp and author fields
* @param {Object} updateData - The update data to compare
* @param {Array} versions - The existing versions array
* @returns {Object|null} - The matching version if found, null otherwise
*/
const isDuplicateVersion = (updateData, currentData, versions) => {
if (!versions || versions.length === 0) {
return null;
}
const excludeFields = [
'_id',
'id',
'createdAt',
'updatedAt',
'author',
'created_at',
'updated_at',
'__v',
'agent_ids',
'versions',
];
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0) {
return null;
}
const wouldBeVersion = { ...currentData, ...directUpdates };
const lastVersion = versions[versions.length - 1];
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
let isMatch = true;
for (const field of importantFields) {
if (!wouldBeVersion[field] && !lastVersion[field]) {
continue;
}
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
if (wouldBeVersion[field].length !== lastVersion[field].length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects like tool_kwargs
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeVersion[field]].sort();
const sortedVersion = [...lastVersion[field]].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
} else if (field === 'model_parameters') {
const wouldBeParams = wouldBeVersion[field] || {};
const lastVersionParams = lastVersion[field] || {};
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
isMatch = false;
break;
}
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
}
}
return isMatch ? lastVersion : null;
};
/**
* Update an agent with new data without overwriting existing
* properties, or create a new agent if it doesn't exist.
* When an agent is updated, a copy of the current state will be saved to the versions array.
*
* @param {Object} searchParameter - The search parameters to find the agent to update.
* @param {string} searchParameter.id - The ID of the agent to update.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
* @throws {Error} If the update would create a duplicate version
*/
const updateAgent = async (searchParameter, updateData) => {
const options = { new: true, upsert: false };
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const { __v, _id, id, versions, ...versionData } = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length > 0 && versions && versions.length > 0) {
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions);
if (duplicateVersion) {
const error = new Error(
'Duplicate version: This would create a version identical to an existing one',
);
error.statusCode = 409;
error.details = {
duplicateVersion,
versionIndex: versions.findIndex(
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
),
};
throw error;
}
}
updateData.$push = {
...($push || {}),
versions: {
...versionData,
...directUpdates,
updatedAt: new Date(),
},
};
}
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
};
@ -358,6 +500,38 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
return await getAgent({ id: agentId });
};
/**
* Reverts an agent to a specific version in its version history.
* @param {Object} searchParameter - The search parameters to find the agent to revert.
* @param {string} searchParameter.id - The ID of the agent to revert.
* @param {string} [searchParameter.author] - The user ID of the agent's author.
* @param {number} versionIndex - The index of the version to revert to in the versions array.
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
* @throws {Error} If the agent is not found or the specified version does not exist.
*/
const revertAgentVersion = async (searchParameter, versionIndex) => {
const agent = await Agent.findOne(searchParameter);
if (!agent) {
throw new Error('Agent not found');
}
if (!agent.versions || !agent.versions[versionIndex]) {
throw new Error(`Version ${versionIndex} not found`);
}
const revertToVersion = agent.versions[versionIndex];
const updateData = {
...revertToVersion,
};
delete updateData._id;
delete updateData.id;
delete updateData.versions;
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
};
module.exports = {
Agent,
getAgent,
@ -369,4 +543,5 @@ module.exports = {
updateAgentProjects,
addAgentResourceFile,
removeAgentResourceFiles,
revertAgentVersion,
};

View file

@ -1,7 +1,25 @@
const originalEnv = {
CREDS_KEY: process.env.CREDS_KEY,
CREDS_IV: process.env.CREDS_IV,
};
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
process.env.CREDS_IV = '0123456789abcdef';
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
const {
Agent,
addAgentResourceFile,
removeAgentResourceFiles,
createAgent,
updateAgent,
getAgent,
deleteAgent,
getListAgents,
updateAgentProjects,
} = require('./Agent');
describe('Agent Resource File Operations', () => {
let mongoServer;
@ -15,6 +33,8 @@ describe('Agent Resource File Operations', () => {
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
process.env.CREDS_IV = originalEnv.CREDS_IV;
});
beforeEach(async () => {
@ -332,3 +352,537 @@ describe('Agent Resource File Operations', () => {
expect(finalFileIds).toHaveLength(0);
});
});
describe('Agent CRUD Operations', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should create and get an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const newAgent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
description: 'Test description',
});
expect(newAgent).toBeDefined();
expect(newAgent.id).toBe(agentId);
expect(newAgent.name).toBe('Test Agent');
const retrievedAgent = await getAgent({ id: agentId });
expect(retrievedAgent).toBeDefined();
expect(retrievedAgent.id).toBe(agentId);
expect(retrievedAgent.name).toBe('Test Agent');
expect(retrievedAgent.description).toBe('Test description');
});
test('should delete an agent', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
const agentBeforeDelete = await getAgent({ id: agentId });
expect(agentBeforeDelete).toBeDefined();
await deleteAgent({ id: agentId });
const agentAfterDelete = await getAgent({ id: agentId });
expect(agentAfterDelete).toBeNull();
});
test('should list agents by author', async () => {
const authorId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();
const agentIds = [];
for (let i = 0; i < 5; i++) {
const id = `agent_${uuidv4()}`;
agentIds.push(id);
await createAgent({
id,
name: `Agent ${i}`,
provider: 'test',
model: 'test-model',
author: authorId,
});
}
for (let i = 0; i < 3; i++) {
await createAgent({
id: `other_agent_${uuidv4()}`,
name: `Other Agent ${i}`,
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
}
const result = await getListAgents({ author: authorId.toString() });
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.data).toHaveLength(5);
expect(result.has_more).toBe(true);
for (const agent of result.data) {
expect(agent.author).toBe(authorId.toString());
}
});
test('should update agent projects', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
const projectId3 = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Project Test Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [projectId1],
});
await updateAgent(
{ id: agentId },
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
);
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
const updatedAgent = await getAgent({ id: agentId });
expect(updatedAgent.projectIds).toHaveLength(2);
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
await updateAgent({ id: agentId }, { projectIds: [] });
const emptyProjectsAgent = await getAgent({ id: agentId });
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
const nonExistentId = `agent_${uuidv4()}`;
await expect(
updateAgentProjects({
id: nonExistentId,
projectIds: [projectId1],
}),
).rejects.toThrow();
});
test('should handle ephemeral agent loading', async () => {
const agentId = 'ephemeral_test';
const endpoint = 'openai';
const originalModule = jest.requireActual('librechat-data-provider');
const mockDataProvider = {
...originalModule,
Constants: {
...originalModule.Constants,
EPHEMERAL_AGENT_ID: 'ephemeral_test',
},
};
jest.doMock('librechat-data-provider', () => mockDataProvider);
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'This is a test instruction',
ephemeralAgent: {
execute_code: true,
mcp: ['server1', 'server2'],
},
},
app: {
locals: {
availableTools: {
tool__server1: {},
tool__server2: {},
another_tool: {},
},
},
},
};
const params = {
req: mockReq,
agent_id: agentId,
endpoint,
model_parameters: {
model: 'gpt-4',
temperature: 0.7,
},
};
expect(agentId).toBeDefined();
expect(endpoint).toBeDefined();
jest.dontMock('librechat-data-provider');
});
test('should handle loadAgent functionality and errors', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Test Load Agent',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1', 'tool2'],
});
const agent = await getAgent({ id: agentId });
expect(agent).toBeDefined();
expect(agent.id).toBe(agentId);
expect(agent.name).toBe('Test Load Agent');
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
const loadedAgent = await mockLoadAgent();
expect(loadedAgent).toBeDefined();
expect(loadedAgent.id).toBe(agentId);
const nonExistentId = `agent_${uuidv4()}`;
const nonExistentAgent = await getAgent({ id: nonExistentId });
expect(nonExistentAgent).toBeNull();
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
});
});
describe('Agent Version History', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
test('should create an agent with a single entry in versions array', async () => {
const agentId = `agent_${uuidv4()}`;
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
expect(agent.versions).toBeDefined();
expect(Array.isArray(agent.versions)).toBe(true);
expect(agent.versions).toHaveLength(1);
expect(agent.versions[0].name).toBe('Test Agent');
expect(agent.versions[0].provider).toBe('test');
expect(agent.versions[0].model).toBe('test-model');
});
test('should accumulate version history across multiple updates', async () => {
const agentId = `agent_${uuidv4()}`;
const author = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'First Name',
provider: 'test',
model: 'test-model',
author,
description: 'First description',
});
await updateAgent({ id: agentId }, { name: 'Second Name', description: 'Second description' });
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
expect(finalAgent.versions).toBeDefined();
expect(Array.isArray(finalAgent.versions)).toBe(true);
expect(finalAgent.versions).toHaveLength(4);
expect(finalAgent.versions[0].name).toBe('First Name');
expect(finalAgent.versions[0].description).toBe('First description');
expect(finalAgent.versions[0].model).toBe('test-model');
expect(finalAgent.versions[1].name).toBe('Second Name');
expect(finalAgent.versions[1].description).toBe('Second description');
expect(finalAgent.versions[1].model).toBe('test-model');
expect(finalAgent.versions[2].name).toBe('Third Name');
expect(finalAgent.versions[2].description).toBe('Second description');
expect(finalAgent.versions[2].model).toBe('new-model');
expect(finalAgent.versions[3].name).toBe('Third Name');
expect(finalAgent.versions[3].description).toBe('Final description');
expect(finalAgent.versions[3].model).toBe('new-model');
expect(finalAgent.name).toBe('Third Name');
expect(finalAgent.description).toBe('Final description');
expect(finalAgent.model).toBe('new-model');
});
test('should not include metadata fields in version history', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.versions[0]._id).toBeUndefined();
expect(updatedAgent.versions[0].__v).toBeUndefined();
expect(updatedAgent.versions[0].name).toBe('Test Agent');
expect(updatedAgent.versions[0].author).toBeDefined();
expect(updatedAgent.versions[1]._id).toBeUndefined();
expect(updatedAgent.versions[1].__v).toBeUndefined();
});
test('should not recursively include previous versions', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
expect(finalAgent.versions).toHaveLength(4);
finalAgent.versions.forEach((version) => {
expect(version.versions).toBeUndefined();
});
});
test('should handle MongoDB operators and field updates correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'MongoDB Operator Test',
provider: 'test',
model: 'test-model',
author: authorId,
tools: ['tool1'],
});
await updateAgent(
{ id: agentId },
{
description: 'Updated description',
$push: { tools: 'tool2' },
$addToSet: { projectIds: projectId },
},
);
const firstUpdate = await getAgent({ id: agentId });
expect(firstUpdate.description).toBe('Updated description');
expect(firstUpdate.tools).toContain('tool1');
expect(firstUpdate.tools).toContain('tool2');
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
expect(firstUpdate.versions).toHaveLength(2);
await updateAgent(
{ id: agentId },
{
tools: ['tool2', 'tool3'],
},
);
const secondUpdate = await getAgent({ id: agentId });
expect(secondUpdate.tools).toHaveLength(2);
expect(secondUpdate.tools).toContain('tool2');
expect(secondUpdate.tools).toContain('tool3');
expect(secondUpdate.tools).not.toContain('tool1');
expect(secondUpdate.versions).toHaveLength(3);
await updateAgent(
{ id: agentId },
{
$push: { tools: 'tool3' },
},
);
const thirdUpdate = await getAgent({ id: agentId });
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
expect(toolCount).toBe(2);
expect(thirdUpdate.versions).toHaveLength(4);
});
test('should handle parameter objects correctly', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
await createAgent({
id: agentId,
name: 'Parameters Test',
provider: 'test',
model: 'test-model',
author: authorId,
model_parameters: { temperature: 0.7 },
});
const updatedAgent = await updateAgent(
{ id: agentId },
{ model_parameters: { temperature: 0.8 } },
);
expect(updatedAgent.versions).toHaveLength(2);
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
await updateAgent(
{ id: agentId },
{
model_parameters: {
temperature: 0.8,
max_tokens: 1000,
},
},
);
const complexAgent = await getAgent({ id: agentId });
expect(complexAgent.versions).toHaveLength(3);
expect(complexAgent.model_parameters.temperature).toBe(0.8);
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
await updateAgent({ id: agentId }, { model_parameters: {} });
const emptyParamsAgent = await getAgent({ id: agentId });
expect(emptyParamsAgent.versions).toHaveLength(4);
expect(emptyParamsAgent.model_parameters).toEqual({});
});
test('should detect duplicate versions and reject updates', async () => {
const originalConsoleError = console.error;
console.error = jest.fn();
try {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const projectId1 = new mongoose.Types.ObjectId();
const projectId2 = new mongoose.Types.ObjectId();
const testCases = [
{
name: 'simple field update',
initial: {
name: 'Test Agent',
description: 'Initial description',
},
update: { name: 'Updated Name' },
duplicate: { name: 'Updated Name' },
},
{
name: 'object field update',
initial: {
model_parameters: { temperature: 0.7 },
},
update: { model_parameters: { temperature: 0.8 } },
duplicate: { model_parameters: { temperature: 0.8 } },
},
{
name: 'array field update',
initial: {
tools: ['tool1', 'tool2'],
},
update: { tools: ['tool2', 'tool3'] },
duplicate: { tools: ['tool2', 'tool3'] },
},
{
name: 'projectIds update',
initial: {
projectIds: [projectId1],
},
update: { projectIds: [projectId1, projectId2] },
duplicate: { projectIds: [projectId2, projectId1] },
},
];
for (const testCase of testCases) {
const testAgentId = `agent_${uuidv4()}`;
await createAgent({
id: testAgentId,
provider: 'test',
model: 'test-model',
author: authorId,
...testCase.initial,
});
await updateAgent({ id: testAgentId }, testCase.update);
let error;
try {
await updateAgent({ id: testAgentId }, testCase.duplicate);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toContain('Duplicate version');
expect(error.statusCode).toBe(409);
expect(error.details).toBeDefined();
expect(error.details.duplicateVersion).toBeDefined();
const agent = await getAgent({ id: testAgentId });
expect(agent.versions).toHaveLength(2);
}
} finally {
console.error = originalConsoleError;
}
});
});

View file

@ -23,6 +23,7 @@ const { updateAction, getActions } = require('~/models/Action');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { deleteFileByFilter } = require('~/models/File');
const { revertAgentVersion } = require('~/models/Agent');
const { logger } = require('~/config');
const systemTools = {
@ -104,6 +105,8 @@ const getAgentHandler = async (req, res) => {
return res.status(404).json({ error: 'Agent not found' });
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
const originalUrl = agent.avatar.filepath;
agent.avatar.filepath = await refreshS3Url(agent.avatar);
@ -127,6 +130,7 @@ const getAgentHandler = async (req, res) => {
author: agent.author,
projectIds: agent.projectIds,
isCollaborative: agent.isCollaborative,
version: agent.version,
});
}
return res.status(200).json(agent);
@ -187,6 +191,14 @@ const updateAgentHandler = async (req, res) => {
return res.json(updatedAgent);
} catch (error) {
logger.error('[/Agents/:id] Error updating Agent', error);
if (error.statusCode === 409) {
return res.status(409).json({
error: error.message,
details: error.details,
});
}
res.status(500).json({ error: error.message });
}
};
@ -411,6 +423,66 @@ const uploadAgentAvatarHandler = async (req, res) => {
}
};
/**
* Reverts an agent to a previous version from its version history.
* @route PATCH /agents/:id/revert
* @param {object} req - Express Request object
* @param {object} req.params - Request parameters
* @param {string} req.params.id - The ID of the agent to revert
* @param {object} req.body - Request body
* @param {number} req.body.version_index - The index of the version to revert to
* @param {object} req.user - Authenticated user information
* @param {string} req.user.id - User ID
* @param {string} req.user.role - User role
* @param {ServerResponse} res - Express Response object
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
* @throws {Error} 400 - If version_index is missing
* @throws {Error} 403 - If user doesn't have permission to modify the agent
* @throws {Error} 404 - If agent not found
* @throws {Error} 500 - If there's an internal server error during the reversion process
*/
const revertAgentVersionHandler = async (req, res) => {
try {
const { id } = req.params;
const { version_index } = req.body;
if (version_index === undefined) {
return res.status(400).json({ error: 'version_index is required' });
}
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const isAuthor = existingAgent.author.toString() === req.user.id;
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
const updatedAgent = await revertAgentVersion({ id }, version_index);
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
return res.json(updatedAgent);
} catch (error) {
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
res.status(500).json({ error: error.message });
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
@ -419,4 +491,5 @@ module.exports = {
deleteAgent: deleteAgentHandler,
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
};

View file

@ -78,6 +78,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
*/
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
/**
* Reverts an agent to a previous version.
* @route POST /agents/:id/revert
* @param {string} req.params.id - Agent identifier.
* @param {number} req.body.version_index - Index of the version to revert to.
* @returns {Agent} 200 - success response - application/json
*/
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
/**
* Returns a list of agents.
* @route GET /agents

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

View file

@ -187,6 +187,8 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
return url;
};
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
export const files = () => '/api/files';
export const images = () => `${files()}/images`;

View file

@ -431,6 +431,14 @@ export const listAgents = (params: a.AgentListParams): Promise<a.AgentListRespon
);
};
export const revertAgentVersion = ({
agent_id,
version_index,
}: {
agent_id: string;
version_index: number;
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
/* Tools */
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {

View file

@ -65,6 +65,7 @@ export enum MutationKeys {
updateAgentAction = 'updateAgentAction',
deleteAction = 'deleteAction',
deleteAgentAction = 'deleteAgentAction',
revertAgentVersion = 'revertAgentVersion',
deleteUser = 'deleteUser',
updateRole = 'updateRole',
enableTwoFactor = 'enableTwoFactor',

View file

@ -222,6 +222,7 @@ export type Agent = {
hide_sequential_outputs?: boolean;
artifacts?: ArtifactModes;
recursion_limit?: number;
version?: number;
};
export type TAgentsMap = Record<string, Agent | undefined>;

View file

@ -129,7 +129,20 @@ export type UpdateAgentVariables = {
data: AgentUpdateParams;
};
export type UpdateAgentMutationOptions = MutationOptions<Agent, UpdateAgentVariables>;
export type DuplicateVersionError = Error & {
statusCode?: number;
details?: {
duplicateVersion?: any;
versionIndex?: number
}
};
export type UpdateAgentMutationOptions = MutationOptions<
Agent,
UpdateAgentVariables,
unknown,
DuplicateVersionError
>;
export type DuplicateAgentBody = {
agent_id: string;
@ -159,6 +172,13 @@ export type DeleteAgentActionVariables = {
export type DeleteAgentActionOptions = MutationOptions<void, DeleteAgentActionVariables>;
export type RevertAgentVersionVariables = {
agent_id: string;
version_index: number;
};
export type RevertAgentVersionOptions = MutationOptions<Agent, RevertAgentVersionVariables>;
export type DeleteConversationOptions = MutationOptions<
types.TDeleteConversationResponse,
types.TDeleteConversationRequest

View file

@ -26,6 +26,7 @@ export interface IAgent extends Omit<Document, 'model'> {
conversation_starters?: string[];
tool_resources?: unknown;
projectIds?: Types.ObjectId[];
versions?: Omit<IAgent, 'versions'>[];
}
const agentSchema = new Schema<IAgent>(
@ -115,6 +116,10 @@ const agentSchema = new Schema<IAgent>(
ref: 'Project',
index: true,
},
versions: {
type: [Schema.Types.Mixed],
default: [],
},
},
{
timestamps: true,