diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index d19645d05f..817e9e837c 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -487,16 +487,15 @@ const getAgentHandler = async (req, res, expandProperties = false) => { const id = req.params.id; const author = req.user.id; - // Permissions are validated by middleware before calling this function - // Simply load the agent by ID - const agent = await db.getAgent({ id }); + // Permissions are validated by middleware before calling this function. + // Load the agent with a `version` count but without the heavy `versions` + // array; version history is fetched lazily via GET /agents/:id/versions. + const agent = await db.getAgentWithVersionCount({ id }); if (!agent) { 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) { try { agent.avatar = { @@ -561,6 +560,32 @@ const getAgentHandler = async (req, res, expandProperties = false) => { } }; +/** + * Retrieves an agent's version history. + * Loaded lazily so the editor doesn't transfer large histories up front. + * @route GET /agents/:id/versions + * @param {object} req - Express Request + * @param {object} req.params - Request params + * @param {string} req.params.id - Agent identifier. + * @returns {Promise} 200 - The agent's version history - application/json + * @returns {Error} 404 - Agent not found + */ +const getAgentVersionsHandler = async (req, res) => { + try { + const id = req.params.id; + const versions = await db.getAgentVersions({ id }); + + if (versions == null) { + return res.status(404).json({ error: 'Agent not found' }); + } + + return res.status(200).json(versions); + } catch (error) { + logger.error('[/Agents/:id/versions] Error retrieving agent versions', error); + res.status(500).json({ error: error.message }); + } +}; + /** * Updates an Agent. * @route PATCH /Agents/:id @@ -1330,6 +1355,7 @@ const getAgentCategories = async (_req, res) => { module.exports = { createAgent: createAgentHandler, getAgent: getAgentHandler, + getAgentVersions: getAgentVersionsHandler, updateAgent: updateAgentHandler, duplicateAgent: duplicateAgentHandler, deleteAgent: deleteAgentHandler, diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 7710fbb2a5..8111193101 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -79,6 +79,7 @@ jest.mock('~/cache', () => ({ const { createAgent: createAgentHandler, getAgent: getAgentHandler, + getAgentVersions: getAgentVersionsHandler, duplicateAgent: duplicateAgentHandler, revertAgentVersion: revertAgentVersionHandler, updateAgent: updateAgentHandler, @@ -603,6 +604,45 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); }); + describe('getAgentVersionsHandler', () => { + test('returns the version history and excludes it from the basic VIEW response', async () => { + const agent = await Agent.create({ + id: `agent_${uuidv4()}`, + name: 'Versioned Agent', + provider: 'openai', + model: 'gpt-4', + author: mockReq.user.id, + versions: [ + { name: 'V1', provider: 'openai', model: 'gpt-4', updatedAt: new Date() }, + { name: 'V2', provider: 'openai', model: 'gpt-4', updatedAt: new Date() }, + ], + }); + mockReq.params = { id: agent.id }; + + await getAgentHandler(mockReq, mockRes); + const basicResponse = mockRes.json.mock.calls[0][0]; + expect(basicResponse.versions).toBeUndefined(); + expect(basicResponse.version).toBe(2); + + mockRes.json.mockClear(); + await getAgentVersionsHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + const versions = mockRes.json.mock.calls[0][0]; + expect(Array.isArray(versions)).toBe(true); + expect(versions).toHaveLength(2); + expect(versions.map((v) => v.name)).toEqual(['V1', 'V2']); + }); + + test('returns 404 when the agent does not exist', async () => { + mockReq.params = { id: `agent_${uuidv4()}` }; + + await getAgentVersionsHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(404); + }); + }); + describe('updateAgentHandler', () => { let existingAgentId; let existingAgentAuthorId; diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index c4f90d0bd5..233839dcf4 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -81,6 +81,23 @@ router.get( }), (req, res) => v1.getAgent(req, res, true), // Expanded version ); + +/** + * Retrieves an agent's version history (EDIT permission required). + * Loaded lazily so the editor doesn't transfer large histories up front. + * @route GET /agents/:id/versions + * @param {string} req.params.id - Agent identifier. + * @returns {Agent[]} 200 - Agent version history - application/json + */ +router.get( + '/:id/versions', + checkAgentAccess, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'id', + }), + v1.getAgentVersions, +); /** * Updates an agent. * @route PATCH /agents/:id diff --git a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx index f9829f1a7d..0bf3749c46 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx @@ -1,8 +1,12 @@ -import { ChevronLeft } from 'lucide-react'; import { useCallback, useMemo } from 'react'; +import { ChevronLeft } from 'lucide-react'; import { useToastContext } from '@librechat/client'; -import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider'; -import type { AgentWithVersions, VersionContext } from './types'; +import type { AgentWithVersions, VersionContext, VersionRecord } from './types'; +import { + useGetAgentVersionsQuery, + useRevertAgentVersionMutation, + useGetExpandedAgentByIdQuery, +} from '~/data-provider'; import { isActiveVersion } from './isActiveVersion'; import { useAgentPanelContext } from '~/Providers'; import VersionContent from './VersionContent'; @@ -16,7 +20,15 @@ export default function VersionPanel() { const selectedAgentId = agent_id ?? ''; - const { data: agent, isLoading, error, refetch } = useGetAgentByIdQuery(selectedAgentId); + const { data: agent } = useGetExpandedAgentByIdQuery(selectedAgentId, { + enabled: !!selectedAgentId, + }); + const { + data: versionsData, + isLoading, + error, + refetch, + } = useGetAgentVersionsQuery(selectedAgentId); const revertAgentVersion = useRevertAgentVersionMutation({ onSuccess: () => { @@ -34,7 +46,7 @@ export default function VersionPanel() { }, }); - const agentWithVersions = agent as AgentWithVersions; + const agentWithVersions = agent as AgentWithVersions | undefined; const currentAgent = useMemo(() => { if (!agentWithVersions) return null; @@ -48,14 +60,15 @@ export default function VersionPanel() { }; }, [agentWithVersions]); + const versionRecords = useMemo(() => versionsData ?? [], [versionsData]); + const versions = useMemo(() => { - const versionsCopy = [...(agentWithVersions?.versions || [])]; - return versionsCopy.sort((a, b) => { + return [...versionRecords].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]); + }, [versionRecords]); const activeVersion = useMemo(() => { return versions.length > 0 @@ -73,7 +86,7 @@ export default function VersionPanel() { return versions.map((version, displayIndex) => { const originalIndex = - agentWithVersions?.versions?.findIndex( + versionRecords.findIndex( (v) => v.updatedAt === version.updatedAt && v.createdAt === version.createdAt && @@ -87,7 +100,7 @@ export default function VersionPanel() { isActive: displayIndex === activeVersionId, }; }); - }, [versions, currentAgent, agentWithVersions?.versions]); + }, [versions, currentAgent, versionRecords]); const versionContext: VersionContext = useMemo( () => ({ diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx index 3258de3d66..f8626ed586 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx @@ -1,8 +1,8 @@ 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'; +import { Panel } from '~/common/types'; const mockAgentData = { name: 'Test Agent', @@ -10,35 +10,42 @@ const mockAgentData = { 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', - }, - ], }; +const mockVersions = [ + { + 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(() => ({ + useGetExpandedAgentByIdQuery: jest.fn(() => ({ data: mockAgentData, isLoading: false, error: null, refetch: jest.fn(), })), + useGetAgentVersionsQuery: jest.fn(() => ({ + data: mockVersions, + isLoading: false, + error: null, + refetch: jest.fn(), + })), useRevertAgentVersionMutation: jest.fn(() => ({ mutate: jest.fn(), isLoading: false, @@ -67,16 +74,24 @@ describe('VersionPanel', () => { '~/Providers/AgentPanelContext', ).useAgentPanelContext; - const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery; + const mockUseGetExpandedAgentByIdQuery = + jest.requireMock('~/data-provider').useGetExpandedAgentByIdQuery; + const mockUseGetAgentVersionsQuery = jest.requireMock('~/data-provider').useGetAgentVersionsQuery; beforeEach(() => { jest.clearAllMocks(); - mockUseGetAgentByIdQuery.mockReturnValue({ + mockUseGetExpandedAgentByIdQuery.mockReturnValue({ data: mockAgentData, isLoading: false, error: null, refetch: jest.fn(), }); + mockUseGetAgentVersionsQuery.mockReturnValue({ + data: mockVersions, + isLoading: false, + error: null, + refetch: jest.fn(), + }); // Set up the default context mock mockUseAgentPanelContext.mockReturnValue({ @@ -126,7 +141,13 @@ describe('VersionPanel', () => { ); // Test with null data - mockUseGetAgentByIdQuery.mockReturnValueOnce({ + mockUseGetExpandedAgentByIdQuery.mockReturnValueOnce({ + data: null, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + mockUseGetAgentVersionsQuery.mockReturnValueOnce({ data: null, isLoading: false, error: null, @@ -150,8 +171,8 @@ describe('VersionPanel', () => { ); // 3. versions is undefined - mockUseGetAgentByIdQuery.mockReturnValueOnce({ - data: { ...mockAgentData, versions: undefined }, + mockUseGetAgentVersionsQuery.mockReturnValueOnce({ + data: undefined, isLoading: false, error: null, refetch: jest.fn(), @@ -165,7 +186,7 @@ describe('VersionPanel', () => { ); // 4. loading state - mockUseGetAgentByIdQuery.mockReturnValueOnce({ + mockUseGetAgentVersionsQuery.mockReturnValueOnce({ data: null, isLoading: true, error: null, @@ -179,7 +200,7 @@ describe('VersionPanel', () => { // 5. error state const testError = new Error('Test error'); - mockUseGetAgentByIdQuery.mockReturnValueOnce({ + mockUseGetAgentVersionsQuery.mockReturnValueOnce({ data: null, isLoading: false, error: testError, @@ -193,12 +214,18 @@ describe('VersionPanel', () => { }); test('memoizes agent data correctly', () => { - mockUseGetAgentByIdQuery.mockReturnValueOnce({ + mockUseGetExpandedAgentByIdQuery.mockReturnValueOnce({ data: mockAgentData, isLoading: false, error: null, refetch: jest.fn(), }); + mockUseGetAgentVersionsQuery.mockReturnValueOnce({ + data: mockVersions, + isLoading: false, + error: null, + refetch: jest.fn(), + }); render(); expect(VersionContent).toHaveBeenCalledWith( diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 951e694ec8..6c36b3c6f9 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -379,6 +379,11 @@ export const useRevertAgentVersionMutation = ( onError: (error, variables, context) => options?.onError?.(error, variables, context), onSuccess: (revertedAgent, variables, context) => { queryClient.setQueryData([QueryKeys.agent, variables.agent_id], revertedAgent); + queryClient.setQueryData( + [QueryKeys.agent, variables.agent_id, 'expanded'], + revertedAgent, + ); + queryClient.invalidateQueries([QueryKeys.agent, variables.agent_id, 'versions']); ((keys: t.AgentListParams[]) => { keys.forEach((key) => { diff --git a/client/src/data-provider/Agents/queries.ts b/client/src/data-provider/Agents/queries.ts index 3040de4d9d..4dc03b7bd2 100644 --- a/client/src/data-provider/Agents/queries.ts +++ b/client/src/data-provider/Agents/queries.ts @@ -133,6 +133,33 @@ export const useGetExpandedAgentByIdQuery = ( ); }; +/** + * Hook for lazily retrieving an agent's version history (EDIT permission). + * Only fetched when the user opens version history, so editors with large + * histories don't pay the cost on every open. + */ +export const useGetAgentVersionsQuery = ( + agent_id: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const isValidAgentId = !!agent_id && !isEphemeralAgent(agent_id); + + return useQuery( + [QueryKeys.agent, agent_id, 'versions'], + () => + dataService.getAgentVersions({ + agent_id: agent_id as string, + }), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + ...config, + enabled: isValidAgentId && (config?.enabled ?? true), + }, + ); +}; + /** * MARKETPLACE */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 4a4721d186..87d216e27f 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -525,6 +525,14 @@ export const getExpandedAgentById = ({ agent_id }: { agent_id: string }): Promis ); }; +export const getAgentVersions = ({ agent_id }: { agent_id: string }): Promise => { + return request.get( + endpoints.agents({ + path: `${agent_id}/versions`, + }), + ); +}; + export const updateAgent = ({ agent_id, data, diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index 82c8f6aac4..09e9b18713 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -47,6 +47,8 @@ let methods: ReturnType; let createAgent: AgentMethods['createAgent']; let getAgent: AgentMethods['getAgent']; +let getAgentVersions: AgentMethods['getAgentVersions']; +let getAgentWithVersionCount: AgentMethods['getAgentWithVersionCount']; let updateAgent: AgentMethods['updateAgent']; let deleteAgent: AgentMethods['deleteAgent']; let deleteUserAgents: AgentMethods['deleteUserAgents']; @@ -90,6 +92,8 @@ beforeAll(async () => { }); createAgent = methods.createAgent; getAgent = methods.getAgent; + getAgentVersions = methods.getAgentVersions; + getAgentWithVersionCount = methods.getAgentWithVersionCount; updateAgent = methods.updateAgent; deleteAgent = methods.deleteAgent; deleteUserAgents = methods.deleteUserAgents; @@ -1476,6 +1480,55 @@ describe('Agent Methods', () => { expect(agent!.versions![0].model).toBe('test-model'); }); + test('getAgentVersions returns only the versions array', async () => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'First Name', + provider: 'test', + model: 'test-model', + author: new mongoose.Types.ObjectId(), + }); + await updateAgent({ id: agentId }, { name: 'Second Name' }); + + const versions = await getAgentVersions({ id: agentId }); + + expect(Array.isArray(versions)).toBe(true); + expect(versions).toHaveLength(2); + expect(versions![0].name).toBe('First Name'); + expect(versions![1].name).toBe('Second Name'); + }); + + test('getAgentVersions returns null for a non-existent agent', async () => { + const versions = await getAgentVersions({ id: `agent_${uuidv4()}` }); + expect(versions).toBeNull(); + }); + + test('getAgentWithVersionCount returns the count without the versions array', async () => { + const agentId = `agent_${uuidv4()}`; + await createAgent({ + id: agentId, + name: 'First Name', + provider: 'test', + model: 'test-model', + author: new mongoose.Types.ObjectId(), + }); + await updateAgent({ id: agentId }, { name: 'Second Name' }); + await updateAgent({ id: agentId }, { name: 'Third Name' }); + + const agent = await getAgentWithVersionCount({ id: agentId }); + + expect(agent).not.toBeNull(); + expect(agent!.name).toBe('Third Name'); + expect(agent!.version).toBe(3); + expect(agent!.versions).toBeUndefined(); + }); + + test('getAgentWithVersionCount returns null for a non-existent agent', async () => { + const agent = await getAgentWithVersionCount({ id: `agent_${uuidv4()}` }); + expect(agent).toBeNull(); + }); + test('should accumulate version history across multiple updates', async () => { const agentId = `agent_${uuidv4()}`; const author = new mongoose.Types.ObjectId(); diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index f9406112f5..7e6cd99f09 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -252,6 +252,10 @@ export function createAgentMethods( deps: AgentDeps, ): { getAgent: (searchParameter: FilterQuery) => Promise; + getAgentVersions: (searchParameter: FilterQuery) => Promise; + getAgentWithVersionCount: ( + searchParameter: FilterQuery, + ) => Promise<(IAgent & { version: number }) | null>; getAgents: (searchParameter: FilterQuery) => Promise; createAgent: (agentData: Record) => Promise; hasAgentWithMCPServerName: ({ @@ -366,6 +370,40 @@ export function createAgentMethods( return await Agent.findOne(searchParameter).lean(); } + /** + * Get an agent's version history only, without the rest of the document. + * Returns an empty array when the agent exists but has no versions, or `null` + * when no agent matches the search parameter. + */ + async function getAgentVersions( + searchParameter: FilterQuery, + ): Promise { + const Agent = mongoose.models.Agent as Model; + const result = await Agent.findOne(searchParameter, { versions: 1, _id: 0 }).lean< + Pick + >(); + if (!result) { + return null; + } + return result.versions ?? []; + } + + /** + * Get an agent document with a `version` count, excluding the heavy `versions` array. + * Used when loading the editor so large version histories aren't transferred eagerly. + */ + async function getAgentWithVersionCount( + searchParameter: FilterQuery, + ): Promise<(IAgent & { version: number }) | null> { + const Agent = mongoose.models.Agent as Model; + const [agent] = await Agent.aggregate([ + { $match: searchParameter }, + { $addFields: { version: { $size: { $ifNull: ['$versions', []] } } } }, + { $project: { versions: 0 } }, + ]); + return agent ?? null; + } + /** * Get multiple agent documents based on the provided search parameters. */ @@ -994,6 +1032,8 @@ export function createAgentMethods( return { getAgent, + getAgentVersions, + getAgentWithVersionCount, getAgents, createAgent, hasAgentWithMCPServerName,