"""Tests for the microagent system.""" import tempfile from pathlib import Path import pytest from openhands.microagent import ( BaseMicroagent, KnowledgeMicroagent, MicroagentMetadata, MicroagentType, RepoMicroagent, load_microagents_from_dir, ) CONTENT = '# dummy header\ndummy content\n## dummy subheader\ndummy subcontent\n' def test_legacy_micro_agent_load(tmp_path): """Test loading of legacy microagents.""" legacy_file = tmp_path / '.openhands_instructions' legacy_file.write_text(CONTENT) # Pass microagent_dir (tmp_path in this case) to load micro_agent = BaseMicroagent.load(legacy_file, tmp_path) assert isinstance(micro_agent, RepoMicroagent) assert micro_agent.name == 'repo_legacy' # Legacy name is hardcoded assert micro_agent.content == CONTENT assert micro_agent.type == MicroagentType.REPO_KNOWLEDGE @pytest.fixture def temp_microagents_dir(): """Create a temporary directory with test microagents.""" with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) # Create test knowledge agent (type inferred from triggers) knowledge_agent = """--- # type: knowledge version: 1.0.0 agent: CodeActAgent triggers: - test - pytest --- # Test Guidelines Testing best practices and guidelines. """ (root / 'knowledge.md').write_text(knowledge_agent) # Create test repo agent (type inferred from lack of triggers) repo_agent = """--- # type: repo version: 1.0.0 agent: CodeActAgent --- # Test Repository Agent Repository-specific test instructions. """ (root / 'repo.md').write_text(repo_agent) yield root def test_knowledge_agent(): """Test knowledge agent functionality.""" # Note: We still pass type to the constructor here, as it expects it. # The loader infers the type before calling the constructor. agent = KnowledgeMicroagent( name='test', content='Test content', metadata=MicroagentMetadata(name='test', triggers=['test', 'pytest']), source='test.md', type=MicroagentType.KNOWLEDGE, # Constructor still needs type ) assert agent.match_trigger('running a test') == 'test' assert agent.match_trigger('using pytest') == 'test' assert agent.match_trigger('no match here') is None assert agent.triggers == ['test', 'pytest'] def test_load_microagents(temp_microagents_dir): """Test loading microagents from directory.""" repo_agents, knowledge_agents = load_microagents_from_dir(temp_microagents_dir) # Check knowledge agents (name derived from filename: knowledge.md -> 'knowledge') assert len(knowledge_agents) == 1 agent_k = knowledge_agents['knowledge'] assert isinstance(agent_k, KnowledgeMicroagent) assert agent_k.type == MicroagentType.KNOWLEDGE # Check inferred type assert 'test' in agent_k.triggers # Check repo agents (name derived from filename: repo.md -> 'repo') assert len(repo_agents) == 1 agent_r = repo_agents['repo'] assert isinstance(agent_r, RepoMicroagent) assert agent_r.type == MicroagentType.REPO_KNOWLEDGE # Check inferred type def test_load_microagents_with_nested_dirs(temp_microagents_dir): """Test loading microagents from nested directories.""" # Create nested knowledge agent nested_dir = temp_microagents_dir / 'nested' / 'dir' nested_dir.mkdir(parents=True) nested_agent = """--- # type: knowledge version: 1.0.0 agent: CodeActAgent triggers: - nested --- # Nested Test Guidelines Testing nested directory loading. """ (nested_dir / 'nested.md').write_text(nested_agent) repo_agents, knowledge_agents = load_microagents_from_dir(temp_microagents_dir) # Check that we can find the nested agent (name derived from path: nested/dir/nested.md -> 'nested/dir/nested') assert ( len(knowledge_agents) == 2 ) # Original ('knowledge') + nested ('nested/dir/nested') agent_n = knowledge_agents['nested/dir/nested'] assert isinstance(agent_n, KnowledgeMicroagent) assert agent_n.type == MicroagentType.KNOWLEDGE # Check inferred type assert 'nested' in agent_n.triggers def test_load_microagents_with_trailing_slashes(temp_microagents_dir): """Test loading microagents when directory paths have trailing slashes.""" # Create a directory with trailing slash knowledge_dir = temp_microagents_dir / 'test_knowledge/' knowledge_dir.mkdir(exist_ok=True) knowledge_agent = """--- # type: knowledge version: 1.0.0 agent: CodeActAgent triggers: - trailing --- # Trailing Slash Test Testing loading with trailing slashes. """ (knowledge_dir / 'trailing.md').write_text(knowledge_agent) repo_agents, knowledge_agents = load_microagents_from_dir( str(temp_microagents_dir) + '/' # Add trailing slash to test ) # Check that we can find the agent despite trailing slashes (name derived from path: test_knowledge/trailing.md -> 'test_knowledge/trailing') assert ( len(knowledge_agents) == 2 ) # Original ('knowledge') + trailing ('test_knowledge/trailing') agent_t = knowledge_agents['test_knowledge/trailing'] assert isinstance(agent_t, KnowledgeMicroagent) assert agent_t.type == MicroagentType.KNOWLEDGE # Check inferred type assert 'trailing' in agent_t.triggers def test_invalid_microagent_type(temp_microagents_dir): """Test loading a microagent with an invalid type.""" # Create a microagent with an invalid type invalid_agent = """--- name: invalid_type_agent type: invalid_type version: 1.0.0 agent: CodeActAgent triggers: - test --- # Invalid Type Test This microagent has an invalid type. """ invalid_file = temp_microagents_dir / 'invalid_type.md' invalid_file.write_text(invalid_agent) # Attempt to load the microagent should raise a MicroagentValidationError from openhands.core.exceptions import MicroagentValidationError with pytest.raises(MicroagentValidationError) as excinfo: load_microagents_from_dir(temp_microagents_dir) # Check that the error message contains helpful information error_msg = str(excinfo.value) assert 'invalid_type.md' in error_msg assert 'Invalid "type" value: "invalid_type"' in error_msg assert 'Valid types are:' in error_msg assert '"knowledge"' in error_msg assert '"repo"' in error_msg assert '"task"' in error_msg def test_cursorrules_file_load(): """Test loading .cursorrules file as a RepoMicroagent.""" cursorrules_content = """Always use Python for new files. Follow the existing code style. Add proper error handling.""" cursorrules_path = Path('.cursorrules') # Test loading .cursorrules file directly agent = BaseMicroagent.load(cursorrules_path, file_content=cursorrules_content) # Verify it's loaded as a RepoMicroagent assert isinstance(agent, RepoMicroagent) assert agent.name == 'cursorrules' assert agent.content == cursorrules_content assert agent.type == MicroagentType.REPO_KNOWLEDGE assert agent.metadata.name == 'cursorrules' assert agent.source == str(cursorrules_path) def test_microagent_version_as_integer(): """Test loading a microagent with version as integer (reproduces the bug).""" # Create a microagent with version as an unquoted integer # This should be parsed as an integer by YAML but converted to string by our code microagent_content = """--- name: test_agent type: knowledge version: 2512312 agent: CodeActAgent triggers: - test --- # Test Agent This is a test agent with integer version. """ test_path = Path('test_agent.md') # This should not raise an error even though version is an integer in YAML agent = BaseMicroagent.load(test_path, file_content=microagent_content) # Verify the agent was loaded correctly assert isinstance(agent, KnowledgeMicroagent) assert agent.name == 'test_agent' assert agent.metadata.version == '2512312' # Should be converted to string assert isinstance(agent.metadata.version, str) # Ensure it's actually a string assert agent.type == MicroagentType.KNOWLEDGE def test_microagent_version_as_float(): """Test loading a microagent with version as float.""" # Create a microagent with version as an unquoted float microagent_content = """--- name: test_agent_float type: knowledge version: 1.5 agent: CodeActAgent triggers: - test --- # Test Agent Float This is a test agent with float version. """ test_path = Path('test_agent_float.md') # This should not raise an error even though version is a float in YAML agent = BaseMicroagent.load(test_path, file_content=microagent_content) # Verify the agent was loaded correctly assert isinstance(agent, KnowledgeMicroagent) assert agent.name == 'test_agent_float' assert agent.metadata.version == '1.5' # Should be converted to string assert isinstance(agent.metadata.version, str) # Ensure it's actually a string assert agent.type == MicroagentType.KNOWLEDGE def test_microagent_version_as_string_unchanged(): """Test loading a microagent with version as string (should remain unchanged).""" # Create a microagent with version as a quoted string microagent_content = """--- name: test_agent_string type: knowledge version: "1.0.0" agent: CodeActAgent triggers: - test --- # Test Agent String This is a test agent with string version. """ test_path = Path('test_agent_string.md') # This should work normally agent = BaseMicroagent.load(test_path, file_content=microagent_content) # Verify the agent was loaded correctly assert isinstance(agent, KnowledgeMicroagent) assert agent.name == 'test_agent_string' assert agent.metadata.version == '1.0.0' # Should remain as string assert isinstance(agent.metadata.version, str) # Ensure it's actually a string assert agent.type == MicroagentType.KNOWLEDGE @pytest.fixture def temp_microagents_dir_with_cursorrules(): """Create a temporary directory with test microagents and .cursorrules file.""" with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) # Create .openhands/microagents directory structure microagents_dir = root / '.openhands' / 'microagents' microagents_dir.mkdir(parents=True, exist_ok=True) # Create .cursorrules file in repository root cursorrules_content = """Always use TypeScript for new files. Follow the existing code style.""" (root / '.cursorrules').write_text(cursorrules_content) # Create test repo agent repo_agent = """--- # type: repo version: 1.0.0 agent: CodeActAgent --- # Test Repository Agent Repository-specific test instructions. """ (microagents_dir / 'repo.md').write_text(repo_agent) yield root def test_load_microagents_with_cursorrules(temp_microagents_dir_with_cursorrules): """Test loading microagents when .cursorrules file exists.""" microagents_dir = ( temp_microagents_dir_with_cursorrules / '.openhands' / 'microagents' ) repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir) # Verify that .cursorrules file was loaded as a RepoMicroagent assert len(repo_agents) == 2 # repo.md + .cursorrules assert 'repo' in repo_agents assert 'cursorrules' in repo_agents # Check .cursorrules agent cursorrules_agent = repo_agents['cursorrules'] assert isinstance(cursorrules_agent, RepoMicroagent) assert cursorrules_agent.name == 'cursorrules' assert 'Always use TypeScript for new files' in cursorrules_agent.content assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE @pytest.fixture def temp_dir_with_cursorrules_only(): """Create a temporary directory with only .cursorrules file (no .openhands/microagents directory).""" with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) # Create .cursorrules file in repository root cursorrules_content = """Always use Python for new files. Follow PEP 8 style guidelines.""" (root / '.cursorrules').write_text(cursorrules_content) # Note: We intentionally do NOT create .openhands/microagents directory yield root def test_load_cursorrules_without_microagents_dir(temp_dir_with_cursorrules_only): """Test loading .cursorrules file when .openhands/microagents directory doesn't exist. This test reproduces the bug where .cursorrules is only loaded when .openhands/microagents directory exists. """ # Try to load from non-existent microagents directory microagents_dir = temp_dir_with_cursorrules_only / '.openhands' / 'microagents' repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir) # This should find the .cursorrules file even though microagents_dir doesn't exist assert len(repo_agents) == 1 # Only .cursorrules assert 'cursorrules' in repo_agents assert len(knowledge_agents) == 0 # Check .cursorrules agent cursorrules_agent = repo_agents['cursorrules'] assert isinstance(cursorrules_agent, RepoMicroagent) assert cursorrules_agent.name == 'cursorrules' assert 'Always use Python for new files' in cursorrules_agent.content assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE def test_agents_md_file_load(): """Test loading AGENTS.md file as a RepoMicroagent.""" agents_content = """# Project Setup ## Setup commands - Install deps: `npm install` - Start dev server: `npm run dev` - Run tests: `npm test` ## Code style - TypeScript strict mode - Single quotes, no semicolons - Use functional patterns where possible""" agents_path = Path('AGENTS.md') # Test loading AGENTS.md file directly agent = BaseMicroagent.load(agents_path, file_content=agents_content) # Verify it's loaded as a RepoMicroagent assert isinstance(agent, RepoMicroagent) assert agent.name == 'agents' assert agent.content == agents_content assert agent.type == MicroagentType.REPO_KNOWLEDGE assert agent.metadata.name == 'agents' assert agent.source == str(agents_path) def test_agents_md_case_insensitive(): """Test that AGENTS.md loading is case-insensitive.""" agents_content = """# Development Guide Use TypeScript for all new files.""" test_cases = ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md'] for filename in test_cases: agents_path = Path(filename) agent = BaseMicroagent.load(agents_path, file_content=agents_content) assert isinstance(agent, RepoMicroagent) assert agent.name == 'agents' assert agent.content == agents_content assert agent.type == MicroagentType.REPO_KNOWLEDGE @pytest.fixture def temp_dir_with_agents_md_only(): """Create a temporary directory with only AGENTS.md file (no .openhands/microagents directory).""" with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) # Create AGENTS.md file in repository root agents_content = """# Development Guide ## Setup commands - Install deps: `poetry install` - Start dev server: `poetry run python app.py` - Run tests: `poetry run pytest` ## Code style - Python 3.12+ - Follow PEP 8 guidelines - Use type hints everywhere""" (root / 'AGENTS.md').write_text(agents_content) # Note: We intentionally do NOT create .openhands/microagents directory yield root def test_load_agents_md_without_microagents_dir(temp_dir_with_agents_md_only): """Test loading AGENTS.md file when .openhands/microagents directory doesn't exist.""" # Try to load from non-existent microagents directory microagents_dir = temp_dir_with_agents_md_only / '.openhands' / 'microagents' repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir) # This should find the AGENTS.md file even though microagents_dir doesn't exist assert len(repo_agents) == 1 # Only AGENTS.md assert 'agents' in repo_agents assert len(knowledge_agents) == 0 # Check AGENTS.md agent agents_agent = repo_agents['agents'] assert isinstance(agents_agent, RepoMicroagent) assert agents_agent.name == 'agents' assert 'Install deps: `poetry install`' in agents_agent.content assert agents_agent.type == MicroagentType.REPO_KNOWLEDGE @pytest.fixture def temp_dir_with_both_cursorrules_and_agents(): """Create a temporary directory with both .cursorrules and AGENTS.md files.""" with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) # Create .cursorrules file cursorrules_content = """Always use Python for new files. Follow PEP 8 style guidelines.""" (root / '.cursorrules').write_text(cursorrules_content) # Create AGENTS.md file agents_content = """# Development Guide ## Setup commands - Install deps: `poetry install` - Run tests: `poetry run pytest`""" (root / 'AGENTS.md').write_text(agents_content) yield root def test_load_both_cursorrules_and_agents_md(temp_dir_with_both_cursorrules_and_agents): """Test loading both .cursorrules and AGENTS.md files when .openhands/microagents doesn't exist.""" # Try to load from non-existent microagents directory microagents_dir = ( temp_dir_with_both_cursorrules_and_agents / '.openhands' / 'microagents' ) repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir) # This should find both files assert len(repo_agents) == 2 # .cursorrules + AGENTS.md assert 'cursorrules' in repo_agents assert 'agents' in repo_agents assert len(knowledge_agents) == 0 # Check both agents cursorrules_agent = repo_agents['cursorrules'] assert isinstance(cursorrules_agent, RepoMicroagent) assert 'Always use Python for new files' in cursorrules_agent.content agents_agent = repo_agents['agents'] assert isinstance(agents_agent, RepoMicroagent) assert 'Install deps: `poetry install`' in agents_agent.content