Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
AgentManager.test.tsโ€ข17.2 kB
/** * Unit tests for AgentManager implementation */ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; // Mock the security modules before importing anything that uses them jest.mock('../../../../../src/security/fileLockManager.js'); jest.mock('../../../../../src/security/securityMonitor.js'); jest.mock('../../../../../src/utils/logger.js'); jest.mock('../../../../../src/portfolio/PortfolioManager.js'); // Import after mocking import { AgentManager } from '../../../../../src/elements/agents/AgentManager.js'; import { Agent } from '../../../../../src/elements/agents/Agent.js'; import { AgentMetadata } from '../../../../../src/elements/agents/types.js'; import { ElementType } from '../../../../../src/portfolio/types.js'; import { PortfolioManager } from '../../../../../src/portfolio/PortfolioManager.js'; import { FileLockManager } from '../../../../../src/security/fileLockManager.js'; import { SecurityMonitor } from '../../../../../src/security/securityMonitor.js'; describe('AgentManager', () => { let agentManager: AgentManager; let testDir: string; let portfolioPath: string; let mockPortfolioManager: any; beforeEach(async () => { // Create temporary test directory testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-test-')); portfolioPath = testDir; // Set up default PortfolioManager mock before creating AgentManager mockPortfolioManager = { listElements: jest.fn<() => Promise<any[]>>().mockResolvedValue([]), getInstance: jest.fn() }; (PortfolioManager.getInstance as any) = jest.fn(() => mockPortfolioManager); agentManager = new AgentManager(portfolioPath); // Initialize manager await agentManager.initialize(); // Set up mocks jest.clearAllMocks(); // Set default mock implementations - simplified for Jest compatibility (FileLockManager as any).atomicWriteFile = jest.fn(() => Promise.resolve(undefined)); (FileLockManager as any).atomicReadFile = jest.fn(() => Promise.resolve('')); (FileLockManager as any).withLock = jest.fn((resource: string, operation: () => Promise<any>) => operation()); (SecurityMonitor as any).logSecurityEvent = jest.fn(); // Mock fs.open for atomic file creation const mockFileHandle = { writeFile: jest.fn(() => Promise.resolve()), close: jest.fn(() => Promise.resolve()) }; jest.spyOn(fs, 'open').mockImplementation(() => Promise.resolve(mockFileHandle as any)); }); afterEach(async () => { // Clean up test directory await fs.rm(testDir, { recursive: true, force: true }); }); describe('Initialization', () => { it('should create agents directory structure', async () => { const agentsPath = path.join(portfolioPath, 'agents'); const statePath = path.join(agentsPath, '.state'); const agentsDirExists = await fs.access(agentsPath).then(() => true).catch(() => false); const stateDirExists = await fs.access(statePath).then(() => true).catch(() => false); expect(agentsDirExists).toBe(true); expect(stateDirExists).toBe(true); }); }); describe('Create', () => { it('should create a new agent', async () => { const result = await agentManager.create( 'test-agent', 'A test agent', 'Agent instructions here', { specializations: ['testing'], decisionFramework: 'rule_based' } ); expect(result.success).toBe(true); expect(result.message).toContain('test-agent'); expect(result.element).toBeInstanceOf(Agent); expect(fs.open).toHaveBeenCalledWith( expect.stringContaining('test-agent.md'), 'wx' ); }); it('should reject invalid agent names', async () => { const result = await agentManager.create( 'invalid name!', 'Description', 'Content' ); expect(result.success).toBe(false); expect(result.message).toContain('Invalid agent name'); }); it('should detect existing agents', async () => { // First create should succeed const firstResult = await agentManager.create('duplicate', 'First', 'Content'); expect(firstResult.success).toBe(true); // Mock fs.open to throw EEXIST error for duplicate jest.spyOn(fs, 'open').mockImplementationOnce(() => Promise.reject({ code: 'EEXIST' })); // Try to create duplicate const result = await agentManager.create('duplicate', 'Second', 'Content'); expect(result.success).toBe(false); expect(result.message).toContain('already exists'); }); it('should log security event on creation', async () => { await agentManager.create('new-agent', 'Description', 'Content'); expect(SecurityMonitor.logSecurityEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'ELEMENT_CREATED', severity: 'LOW', details: expect.stringContaining('new-agent') }) ); }); }); describe('Read', () => { beforeEach(async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.resolve(`--- name: test-agent type: agent version: 1.0.0 description: Test agent decisionFramework: rule_based specializations: - testing --- # Test Agent Agent instructions here`)); }); it('should read an existing agent', async () => { const agent = await agentManager.read('test-agent'); expect(agent).not.toBeNull(); expect(agent?.metadata.name).toBe('test-agent'); expect(agent?.extensions?.decisionFramework).toBe('rule_based'); }); it('should return null for non-existent agent', async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.reject({ code: 'ENOENT' })); const agent = await agentManager.read('non-existent'); expect(agent).toBeNull(); }); it('should reject oversized files', async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.resolve('x'.repeat(200 * 1024))); // 200KB await expect(agentManager.read('huge-agent')) .rejects.toThrow('exceeds maximum size'); }); it('should load agent state if available', async () => { // Mock both agent file and state file (FileLockManager.atomicReadFile as any) = jest.fn(async (path: string) => { if (path.includes('.state.yaml')) { // Return state file content in YAML frontmatter format return `--- goals: - id: goal_123 description: Test goal status: pending decisions: [] context: key: value lastActive: 2025-01-01T00:00:00Z sessionCount: 5 ---`; } else { // Return agent file content return `--- name: test-agent type: agent --- Content`; } }); const agent = await agentManager.read('test-agent'); const state = agent?.getState(); // Note: sessionCount is stored as string in YAML and parsed back as number expect(state?.sessionCount).toBe(5); expect(state?.context.key).toBe('value'); }); }); describe('Update', () => { it('should update agent metadata', async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.resolve(`--- name: test-agent description: Old description --- Content`)); const success = await agentManager.update('test-agent', { description: 'New description', specializations: ['updated', 'skills'] }); expect(success).toBe(true); expect(FileLockManager.atomicWriteFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining('New description'), expect.any(Object) ); }); it('should return false for non-existent agent', async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.reject({ code: 'ENOENT' })); const success = await agentManager.update('non-existent', { description: 'New' }); expect(success).toBe(false); }); it('should save agent state if dirty', async () => { // Create a mock agent with dirty state const agent = new Agent({ name: 'test-agent' }); agent.addGoal({ description: 'New goal' }); // This makes state dirty // Mock the read to return our agent (FileLockManager.atomicReadFile as any) = jest.fn(async () => { return `--- name: test-agent --- Content`; }); // Mock the manager's read method to return our agent jest.spyOn(agentManager, 'read').mockImplementation(() => Promise.resolve(agent)); await agentManager.update('test-agent', {}); // Should have written both the agent file and state file expect(FileLockManager.atomicWriteFile).toHaveBeenCalledTimes(2); }); }); describe('Delete', () => { it('should delete agent and state files', async () => { const mockUnlink = jest.spyOn(fs, 'unlink').mockImplementation(() => Promise.resolve()); const mockAccess = jest.spyOn(fs, 'access').mockImplementation(() => Promise.resolve()); await agentManager.delete('test-agent'); expect(mockUnlink).toHaveBeenCalledTimes(2); // Main file + state file expect(mockUnlink).toHaveBeenCalledWith( expect.stringContaining('test-agent.md') ); expect(mockUnlink).toHaveBeenCalledWith( expect.stringContaining('test-agent.state.yaml') ); }); it('should log security event on deletion', async () => { const mockUnlink = jest.spyOn(fs, 'unlink').mockImplementation(() => Promise.resolve()); const mockAccess = jest.spyOn(fs, 'access').mockImplementation(() => Promise.resolve()); await agentManager.delete('test-agent'); expect(SecurityMonitor.logSecurityEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'ELEMENT_DELETED', severity: 'MEDIUM', details: expect.stringContaining('test-agent') }) ); }); it('should not throw if agent does not exist', async () => { const mockAccess = jest.spyOn(fs, 'access').mockImplementation(() => Promise.reject({ code: 'ENOENT' })); await expect(agentManager.delete('non-existent')).resolves.not.toThrow(); }); }); describe('List', () => { it('should list all agents', async () => { // Configure the mock to return agent files mockPortfolioManager.listElements.mockResolvedValue(['agent1.md', 'agent2.md']); (FileLockManager.atomicReadFile as any) = jest.fn(async (path: any) => { if (path.includes('agent1')) { return `--- name: agent1 --- Content`; } else { return `--- name: agent2 --- Content`; } }); const agents = await agentManager.list(); expect(agents).toHaveLength(2); expect(agents[0].metadata.name).toBe('agent1'); expect(agents[1].metadata.name).toBe('agent2'); }); it('should handle read errors gracefully', async () => { // Configure mock to return files but atomicReadFile to fail mockPortfolioManager.listElements.mockResolvedValue(['bad.md']); (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.reject(new Error('Read error'))); const agents = await agentManager.list(); expect(agents).toHaveLength(0); }); }); describe('Validation', () => { it('should validate agent names', () => { expect(agentManager.validateName('valid-name').valid).toBe(true); expect(agentManager.validateName('valid_name').valid).toBe(true); expect(agentManager.validateName('valid123').valid).toBe(true); expect(agentManager.validateName('').valid).toBe(false); expect(agentManager.validateName('invalid name').valid).toBe(false); expect(agentManager.validateName('invalid!name').valid).toBe(false); expect(agentManager.validateName('a'.repeat(101)).valid).toBe(false); }); it('should validate paths', () => { expect(agentManager.validatePath('relative/path.md')).toBe(true); expect(agentManager.validatePath('file.md')).toBe(true); expect(agentManager.validatePath('../traversal')).toBe(false); expect(agentManager.validatePath('~/home')).toBe(false); expect(agentManager.validatePath('/absolute/path')).toBe(false); expect(agentManager.validatePath('C:\\windows')).toBe(false); }); }); describe('Import/Export', () => { it('should import agent from JSON', async () => { const agentData = { id: 'test-id', type: ElementType.AGENT, version: '1.0.0', metadata: { name: 'imported-agent', description: 'Imported from JSON', decisionFramework: 'hybrid' }, state: { goals: [{ id: 'goal_123', description: 'Imported goal', status: 'pending' }], decisions: [], context: {}, lastActive: new Date().toISOString(), sessionCount: 0 } }; const agent = await agentManager.importElement(JSON.stringify(agentData), 'json'); expect(agent.metadata.name).toBe('imported-agent'); expect(agent.getState().goals).toHaveLength(1); }); it('should import agent from markdown', async () => { const markdown = `--- name: markdown-agent description: Imported from markdown decisionFramework: programmatic --- # Agent Instructions This is the agent content.`; const agent = await agentManager.importElement(markdown, 'markdown'); expect(agent.metadata.name).toBe('markdown-agent'); expect(agent.extensions?.decisionFramework).toBe('programmatic'); }); it('should export agent to JSON', async () => { const agent = new Agent({ name: 'export-test', description: 'Test export' }); agent.addGoal({ description: 'Test goal' }); const exported = await agentManager.exportElement(agent, 'json'); const parsed = JSON.parse(exported); expect(parsed.metadata.name).toBe('export-test'); expect(parsed.state.goals).toHaveLength(1); }); it('should export agent to markdown', async () => { const agent = new Agent({ name: 'export-test', description: 'Test export' }); const exported = await agentManager.exportElement(agent, 'markdown'); expect(exported).toContain('---'); expect(exported).toContain('name: export-test'); expect(exported).toContain('# export-test'); }); }); describe('State Management', () => { it('should save agent state', async () => { const state = { goals: [], decisions: [], context: { test: 'value' }, lastActive: new Date().toISOString(), sessionCount: 1 }; await agentManager.saveAgentState('test-agent', state as any); // Check that the path contains the expected components (cross-platform) const firstCallArgs = (FileLockManager.atomicWriteFile as jest.Mock).mock.calls[0]; const filePath = firstCallArgs[0]; expect(filePath).toMatch(/[/\\]\.state[/\\]test-agent\.state\.yaml$/); expect(firstCallArgs[1]).toContain('test: value'); expect(firstCallArgs[2]).toEqual(expect.any(Object)); }); it('should reject oversized state', async () => { const hugeState = { goals: [], decisions: [], context: { data: 'x'.repeat(100 * 1024) }, // Exceed limit lastActive: new Date().toISOString(), sessionCount: 1 }; await expect(agentManager.saveAgentState('test-agent', hugeState as any)) .rejects.toThrow('exceeds maximum'); }); it('should cache loaded state', async () => { let callCount = 0; (FileLockManager.atomicReadFile as any) = jest.fn(async (path: string) => { callCount++; if (path.includes('.state.yaml')) { return `--- goals: [] decisions: [] context: {} lastActive: 2025-01-01T00:00:00Z sessionCount: 1 ---`; } else { return `--- name: test-agent --- Content`; } }); // First read loads from file await agentManager.read('test-agent'); const firstCallCount = callCount; expect(firstCallCount).toBe(2); // Agent file + state file // Second read should use cache await agentManager.read('test-agent'); expect(callCount).toBe(3); // Only agent file read again }); }); describe('Error Handling', () => { it('should handle file parse errors', async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.resolve('Invalid YAML content')); await expect(agentManager.read('bad-agent')) .rejects.toThrow('Invalid agent file format'); }); it('should validate element type in files', async () => { (FileLockManager.atomicReadFile as any) = jest.fn(() => Promise.resolve(`--- name: wrong-type type: persona --- Content`)); await expect(agentManager.read('wrong-type')) .rejects.toThrow("Invalid element type: expected 'agents', got 'persona'"); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server