diff --git a/api/models/Agent.js b/api/models/Agent.js index 9b34eeae65..c52c364a63 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -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} 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} 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, }; diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 051cb6800f..3eb866b692 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -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; + } + }); +}); diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index e0f27a13fc..64f8db3c16 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -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} 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, }; diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index f79cec2cdc..657aa79414 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -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 diff --git a/client/src/common/types.ts b/client/src/common/types.ts index ab52bfb007..6837869e8e 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -142,6 +142,7 @@ export enum Panel { builder = 'builder', actions = 'actions', model = 'model', + version = 'version', } export type FileSetter = diff --git a/client/src/components/SidePanel/Agents/AgentFooter.tsx b/client/src/components/SidePanel/Agents/AgentFooter.tsx index ce99e1189f..2b3aa1bcd7 100644 --- a/client/src/components/SidePanel/Agents/AgentFooter.tsx +++ b/client/src/components/SidePanel/Agents/AgentFooter.tsx @@ -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 (
{showButtons && } + {showButtons && agent_id && } {user?.role === SystemRoles.ADMIN && showButtons && } {/* Context Button */}
diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 1cddf15180..d891786142 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -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}` : '' diff --git a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx index ae2566dc56..4dc54c9b60 100644 --- a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx @@ -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(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(() => { + 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 ; } + if (activePanel === Panel.version) { + return ( + + ); + } + return ( ); diff --git a/client/src/components/SidePanel/Agents/Version/VersionButton.tsx b/client/src/components/SidePanel/Agents/Version/VersionButton.tsx new file mode 100644 index 0000000000..5708bd392f --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/VersionButton.tsx @@ -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 ( + + ); +}; + +export default VersionButton; diff --git a/client/src/components/SidePanel/Agents/Version/VersionContent.tsx b/client/src/components/SidePanel/Agents/Version/VersionContent.tsx new file mode 100644 index 0000000000..25b8f5bd5b --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/VersionContent.tsx @@ -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 ( +
+ {localize('com_ui_agent_version_no_agent')} +
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
{localize('com_ui_agent_version_error')}
+ ); + } + + if (versionIds.length > 0) { + return ( +
+ {versionIds.map(({ id, version, isActive }) => ( + + ))} +
+ ); + } + + return ( +
+ {localize('com_ui_agent_version_empty')} +
+ ); +} diff --git a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx new file mode 100644 index 0000000000..c1d27cadaf --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx @@ -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 ( +
+
+ + {localize('com_ui_agent_version_title', { versionNumber: versionsLength - index })} + + {isActive && ( + + {localize('com_ui_agent_version_active')} + + )} +
+
{getVersionTimestamp(version)}
+ {!isActive && ( + + )} +
+ ); +} diff --git a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx new file mode 100644 index 0000000000..9d76aba75a --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx @@ -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; + +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; +} + +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 ( +
+
+
+ +
+
+ {localize('com_ui_agent_version_history')} +
+
+
+ +
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionContent.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionContent.spec.tsx new file mode 100644 index 0000000000..36cbf9eff5 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionContent.spec.tsx @@ -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 }) => ( +
+
{version.name}
+ {!isActive && ( + + )} +
+ )), +})); + +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: () =>
, +})); + +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(); + 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( + , + ); + + 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( + , + ); + expect(getAllByTestId('version-item')).toHaveLength(mockVersionIds.length); + + rerender( + , + ); + expect(getByText('No versions available')).toBeInTheDocument(); + + rerender( + , + ); + expect(getByText('No agent selected')).toBeInTheDocument(); + expect(queryByTestId('spinner')).not.toBeInTheDocument(); + expect(queryByText('Error loading versions')).not.toBeInTheDocument(); + + rerender(); + expect(queryByTestId('spinner')).toBeInTheDocument(); + expect(queryByText('Error loading versions')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx new file mode 100644 index 0000000000..053bf0f224 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx @@ -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(); + 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(); + expect(screen.getByText('Active Version')).toBeInTheDocument(); + expect(screen.queryByText('Restore')).not.toBeInTheDocument(); + }); + + test('restore button and no active badge when not active', () => { + render(); + expect(screen.queryByText('Active Version')).not.toBeInTheDocument(); + expect(screen.getByText('Restore')).toBeInTheDocument(); + }); + + test('restore confirmation flow - confirmed', () => { + render(); + 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(); + fireEvent.click(screen.getByText('Restore')); + expect(window.confirm).toHaveBeenCalled(); + expect(defaultProps.onRestore).not.toHaveBeenCalled(); + }); + + test('handles invalid timestamp', () => { + render( + , + ); + expect(screen.getByText('Unknown date')).toBeInTheDocument(); + }); + + test('handles missing timestamps', () => { + render( + , + ); + 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(); + 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( + , + ); + const createdDate = new Date('2023-01-01T00:00:00Z').toLocaleString(); + expect(screen.getByText(createdDate)).toBeInTheDocument(); + }); + + test('handles empty version object', () => { + render(); + expect(screen.getByText('No date')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx new file mode 100644 index 0000000000..d9cf31eff3 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx @@ -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(() =>
), +})); + +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(); + 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(); + 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(); + expect(VersionContent).toHaveBeenCalledWith( + expect.objectContaining({ selectedAgentId: '' }), + expect.anything(), + ); + + mockUseGetAgentByIdQuery.mockReturnValueOnce({ + data: null, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + render(); + 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(); + expect(VersionContent).toHaveBeenCalledWith( + expect.objectContaining({ + versionContext: expect.objectContaining({ versions: [] }), + }), + expect.anything(), + ); + + mockUseGetAgentByIdQuery.mockReturnValueOnce({ + data: null, + isLoading: true, + error: null, + refetch: jest.fn(), + }); + render(); + 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(); + 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(); + 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(), + ); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts b/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts new file mode 100644 index 0000000000..d6ef786d9c --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts @@ -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); + }); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts new file mode 100644 index 0000000000..61919953dd --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts @@ -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() + ); +}; diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx new file mode 100644 index 0000000000..9a73f94a57 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/AgentFooter.spec.tsx @@ -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 = ( + isLoading = false, +): UseMutationResult => { + 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(), +})); + +jest.mock('../Advanced/AdvancedButton', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +jest.mock('../Version/VersionButton', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +jest.mock('../AdminSettings', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +jest.mock('../DeleteButton', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +jest.mock('../ShareAgent', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +jest.mock('../DuplicateAgent', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); + +jest.mock('~/components', () => ({ + 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(); + const mockUpdateMutation = createBaseMutation(); + + 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(); + 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( + , + ); + 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(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + }); + }); + + describe('Conditional Rendering', () => { + test('adjusts UI based on activePanel state', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 04f40cd3dd..24af4ee898 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -43,7 +43,11 @@ export const useCreateAgentMutation = ( */ export const useUpdateAgentMutation = ( options?: t.UpdateAgentMutationOptions, -): UseMutationResult => { +): 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([ 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 => { + 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([QueryKeys.agent, variables.agent_id], revertedAgent); + + const listRes = queryClient.getQueryData([ + QueryKeys.agents, + defaultOrderQuery, + ]); + + if (listRes) { + queryClient.setQueryData([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); + }, + }, + ); +}; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 77dab2732e..d010b2d33c 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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." -} \ No newline at end of file +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index ddca2f6ce4..a7852affe2 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -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`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 0d5b88a8e5..737b252215 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -431,6 +431,14 @@ export const listAgents = (params: a.AgentListParams): Promise => request.post(endpoints.revertAgentVersion(agent_id), { version_index }); + /* Tools */ export const getAvailableAgentTools = (): Promise => { diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index fd5ee95087..f9e51dc094 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -65,6 +65,7 @@ export enum MutationKeys { updateAgentAction = 'updateAgentAction', deleteAction = 'deleteAction', deleteAgentAction = 'deleteAgentAction', + revertAgentVersion = 'revertAgentVersion', deleteUser = 'deleteUser', updateRole = 'updateRole', enableTwoFactor = 'enableTwoFactor', diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 5685c46724..16f6b7d762 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -222,6 +222,7 @@ export type Agent = { hide_sequential_outputs?: boolean; artifacts?: ArtifactModes; recursion_limit?: number; + version?: number; }; export type TAgentsMap = Record; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 72d98b81b6..7e76ba8fd9 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -129,7 +129,20 @@ export type UpdateAgentVariables = { data: AgentUpdateParams; }; -export type UpdateAgentMutationOptions = MutationOptions; +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; +export type RevertAgentVersionVariables = { + agent_id: string; + version_index: number; +}; + +export type RevertAgentVersionOptions = MutationOptions; + export type DeleteConversationOptions = MutationOptions< types.TDeleteConversationResponse, types.TDeleteConversationRequest diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 642d281c68..784c4f6c35 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -26,6 +26,7 @@ export interface IAgent extends Omit { conversation_starters?: string[]; tool_resources?: unknown; projectIds?: Types.ObjectId[]; + versions?: Omit[]; } const agentSchema = new Schema( @@ -115,6 +116,10 @@ const agentSchema = new Schema( ref: 'Project', index: true, }, + versions: { + type: [Schema.Types.Mixed], + default: [], + }, }, { timestamps: true,