"""Unit tests for the V1 skills endpoint in app_conversation_router. This module tests the GET /{conversation_id}/skills endpoint functionality, following TDD best practices with AAA structure. """ from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest from fastapi import status from openhands.app_server.app_conversation.app_conversation_models import ( AppConversation, ) from openhands.app_server.app_conversation.app_conversation_router import ( get_conversation_skills, ) from openhands.app_server.app_conversation.app_conversation_service_base import ( AppConversationServiceBase, ) from openhands.app_server.sandbox.sandbox_models import ( AGENT_SERVER, ExposedUrl, SandboxInfo, SandboxStatus, ) from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo from openhands.app_server.user.user_context import UserContext from openhands.sdk.context.skills import KeywordTrigger, Skill, TaskTrigger def _make_service_mock( *, user_context: UserContext, conversation_return: AppConversation | None = None, skills_return: list[Skill] | None = None, raise_on_load: bool = False, ): """Create a mock service that passes the isinstance check and returns the desired values.""" mock_cls = type('AppConversationServiceMock', (MagicMock,), {}) AppConversationServiceBase.register(mock_cls) service = mock_cls() service.user_context = user_context service.get_app_conversation = AsyncMock(return_value=conversation_return) async def _load_skills(*_args, **_kwargs): if raise_on_load: raise Exception('Skill loading failed') return skills_return or [] service.load_and_merge_all_skills = AsyncMock(side_effect=_load_skills) return service @pytest.mark.asyncio class TestGetConversationSkills: """Test suite for get_conversation_skills endpoint.""" async def test_get_skills_returns_repo_and_knowledge_skills(self): """Test successful retrieval of both repo and knowledge skills. Arrange: Setup conversation, sandbox, and skills with different types Act: Call get_conversation_skills endpoint Assert: Response contains both repo and knowledge skills with correct types """ # Arrange conversation_id = uuid4() sandbox_id = str(uuid4()) working_dir = '/workspace' # Create mock conversation mock_conversation = AppConversation( id=conversation_id, created_by_user_id='test-user', sandbox_id=sandbox_id, selected_repository='owner/repo', sandbox_status=SandboxStatus.RUNNING, ) # Create mock sandbox with agent server URL mock_sandbox = SandboxInfo( id=sandbox_id, created_by_user_id='test-user', status=SandboxStatus.RUNNING, sandbox_spec_id=str(uuid4()), session_api_key='test-api-key', exposed_urls=[ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) ], ) # Create mock sandbox spec mock_sandbox_spec = SandboxSpecInfo( id=str(uuid4()), command=None, working_dir=working_dir ) # Create mock skills - repo skill (no trigger) repo_skill = Skill( name='repo_skill', content='Repository skill content', trigger=None, ) # Create mock skills - knowledge skill (with KeywordTrigger) knowledge_skill = Skill( name='knowledge_skill', content='Knowledge skill content', trigger=KeywordTrigger(keywords=['test', 'help']), ) # Mock services mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=mock_conversation, skills_return=[repo_skill, knowledge_skill], ) mock_sandbox_service = MagicMock() mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_spec_service = MagicMock() mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( return_value=mock_sandbox_spec ) # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_200_OK content = response.body.decode('utf-8') import json data = json.loads(content) assert 'skills' in data assert len(data['skills']) == 2 # Check repo skill repo_skill_data = next( (s for s in data['skills'] if s['name'] == 'repo_skill'), None ) assert repo_skill_data is not None assert repo_skill_data['type'] == 'repo' assert repo_skill_data['content'] == 'Repository skill content' assert repo_skill_data['triggers'] == [] # Check knowledge skill knowledge_skill_data = next( (s for s in data['skills'] if s['name'] == 'knowledge_skill'), None ) assert knowledge_skill_data is not None assert knowledge_skill_data['type'] == 'knowledge' assert knowledge_skill_data['content'] == 'Knowledge skill content' assert knowledge_skill_data['triggers'] == ['test', 'help'] async def test_get_skills_returns_404_when_conversation_not_found(self): """Test endpoint returns 404 when conversation doesn't exist. Arrange: Setup mocks to return None for conversation Act: Call get_conversation_skills endpoint Assert: Response is 404 with appropriate error message """ # Arrange conversation_id = uuid4() mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=None, ) mock_sandbox_service = MagicMock() mock_sandbox_spec_service = MagicMock() # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_404_NOT_FOUND content = response.body.decode('utf-8') import json data = json.loads(content) assert 'error' in data assert str(conversation_id) in data['error'] async def test_get_skills_returns_404_when_sandbox_not_found(self): """Test endpoint returns 404 when sandbox doesn't exist. Arrange: Setup conversation but no sandbox Act: Call get_conversation_skills endpoint Assert: Response is 404 with sandbox error message """ # Arrange conversation_id = uuid4() sandbox_id = str(uuid4()) mock_conversation = AppConversation( id=conversation_id, created_by_user_id='test-user', sandbox_id=sandbox_id, sandbox_status=SandboxStatus.RUNNING, ) mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=mock_conversation, ) mock_sandbox_service = MagicMock() mock_sandbox_service.get_sandbox = AsyncMock(return_value=None) mock_sandbox_spec_service = MagicMock() # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_404_NOT_FOUND content = response.body.decode('utf-8') import json data = json.loads(content) assert 'error' in data assert 'Sandbox not found' in data['error'] async def test_get_skills_returns_404_when_sandbox_not_running(self): """Test endpoint returns 404 when sandbox is not in RUNNING state. Arrange: Setup conversation with stopped sandbox Act: Call get_conversation_skills endpoint Assert: Response is 404 with sandbox not running message """ # Arrange conversation_id = uuid4() sandbox_id = str(uuid4()) mock_conversation = AppConversation( id=conversation_id, created_by_user_id='test-user', sandbox_id=sandbox_id, sandbox_status=SandboxStatus.PAUSED, ) mock_sandbox = SandboxInfo( id=sandbox_id, created_by_user_id='test-user', status=SandboxStatus.PAUSED, sandbox_spec_id=str(uuid4()), session_api_key='test-api-key', ) mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=mock_conversation, ) mock_sandbox_service = MagicMock() mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_spec_service = MagicMock() # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_404_NOT_FOUND content = response.body.decode('utf-8') import json data = json.loads(content) assert 'error' in data assert 'not running' in data['error'] async def test_get_skills_handles_task_trigger_skills(self): """Test endpoint correctly handles skills with TaskTrigger. Arrange: Setup skill with TaskTrigger Act: Call get_conversation_skills endpoint Assert: Skill is categorized as knowledge type with correct triggers """ # Arrange conversation_id = uuid4() sandbox_id = str(uuid4()) mock_conversation = AppConversation( id=conversation_id, created_by_user_id='test-user', sandbox_id=sandbox_id, sandbox_status=SandboxStatus.RUNNING, ) mock_sandbox = SandboxInfo( id=sandbox_id, created_by_user_id='test-user', status=SandboxStatus.RUNNING, sandbox_spec_id=str(uuid4()), session_api_key='test-api-key', exposed_urls=[ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) ], ) mock_sandbox_spec = SandboxSpecInfo( id=str(uuid4()), command=None, working_dir='/workspace' ) # Create task skill with TaskTrigger task_skill = Skill( name='task_skill', content='Task skill content', trigger=TaskTrigger(triggers=['task', 'execute']), ) mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=mock_conversation, skills_return=[task_skill], ) mock_sandbox_service = MagicMock() mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_spec_service = MagicMock() mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( return_value=mock_sandbox_spec ) # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_200_OK content = response.body.decode('utf-8') import json data = json.loads(content) assert len(data['skills']) == 1 skill_data = data['skills'][0] assert skill_data['type'] == 'knowledge' assert skill_data['triggers'] == ['task', 'execute'] async def test_get_skills_returns_500_on_skill_loading_error(self): """Test endpoint returns 500 when skill loading fails. Arrange: Setup mocks to raise exception during skill loading Act: Call get_conversation_skills endpoint Assert: Response is 500 with error message """ # Arrange conversation_id = uuid4() sandbox_id = str(uuid4()) mock_conversation = AppConversation( id=conversation_id, created_by_user_id='test-user', sandbox_id=sandbox_id, sandbox_status=SandboxStatus.RUNNING, ) mock_sandbox = SandboxInfo( id=sandbox_id, created_by_user_id='test-user', status=SandboxStatus.RUNNING, sandbox_spec_id=str(uuid4()), session_api_key='test-api-key', exposed_urls=[ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) ], ) mock_sandbox_spec = SandboxSpecInfo( id=str(uuid4()), command=None, working_dir='/workspace' ) mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=mock_conversation, raise_on_load=True, ) mock_sandbox_service = MagicMock() mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_spec_service = MagicMock() mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( return_value=mock_sandbox_spec ) # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR content = response.body.decode('utf-8') import json data = json.loads(content) assert 'error' in data assert 'Error getting skills' in data['error'] async def test_get_skills_returns_empty_list_when_no_skills_loaded(self): """Test endpoint returns empty skills list when no skills are found. Arrange: Setup all skill loaders to return empty lists Act: Call get_conversation_skills endpoint Assert: Response contains empty skills array """ # Arrange conversation_id = uuid4() sandbox_id = str(uuid4()) mock_conversation = AppConversation( id=conversation_id, created_by_user_id='test-user', sandbox_id=sandbox_id, sandbox_status=SandboxStatus.RUNNING, ) mock_sandbox = SandboxInfo( id=sandbox_id, created_by_user_id='test-user', status=SandboxStatus.RUNNING, sandbox_spec_id=str(uuid4()), session_api_key='test-api-key', exposed_urls=[ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) ], ) mock_sandbox_spec = SandboxSpecInfo( id=str(uuid4()), command=None, working_dir='/workspace' ) mock_user_context = MagicMock(spec=UserContext) mock_app_conversation_service = _make_service_mock( user_context=mock_user_context, conversation_return=mock_conversation, skills_return=[], ) mock_sandbox_service = MagicMock() mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) mock_sandbox_spec_service = MagicMock() mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( return_value=mock_sandbox_spec ) # Act response = await get_conversation_skills( conversation_id=conversation_id, app_conversation_service=mock_app_conversation_service, sandbox_service=mock_sandbox_service, sandbox_spec_service=mock_sandbox_spec_service, ) # Assert assert response.status_code == status.HTTP_200_OK content = response.body.decode('utf-8') import json data = json.loads(content) assert 'skills' in data assert len(data['skills']) == 0