Spaces:
Build error
Build error
| import json | |
| from contextlib import contextmanager | |
| from datetime import datetime, timezone | |
| from types import MappingProxyType | |
| from unittest.mock import AsyncMock, MagicMock, patch | |
| import pytest | |
| from fastapi import FastAPI | |
| from fastapi.responses import JSONResponse | |
| from fastapi.testclient import TestClient | |
| from openhands.integrations.service_types import ( | |
| AuthenticationError, | |
| ProviderType, | |
| SuggestedTask, | |
| TaskType, | |
| ) | |
| from openhands.server.data_models.conversation_info import ConversationInfo | |
| from openhands.server.data_models.conversation_info_result_set import ( | |
| ConversationInfoResultSet, | |
| ) | |
| from openhands.server.routes.manage_conversations import ( | |
| ConversationResponse, | |
| InitSessionRequest, | |
| delete_conversation, | |
| get_conversation, | |
| new_conversation, | |
| search_conversations, | |
| ) | |
| from openhands.server.routes.manage_conversations import app as conversation_app | |
| from openhands.server.types import LLMAuthenticationError, MissingSettingsError | |
| from openhands.server.user_auth.user_auth import AuthType | |
| from openhands.storage.data_models.conversation_metadata import ( | |
| ConversationMetadata, | |
| ConversationTrigger, | |
| ) | |
| from openhands.storage.data_models.conversation_status import ConversationStatus | |
| from openhands.storage.locations import get_conversation_metadata_filename | |
| from openhands.storage.memory import InMemoryFileStore | |
| def _patch_store(): | |
| file_store = InMemoryFileStore() | |
| file_store.write( | |
| get_conversation_metadata_filename('some_conversation_id'), | |
| json.dumps( | |
| { | |
| 'title': 'Some ServerConversation', | |
| 'selected_repository': 'foobar', | |
| 'conversation_id': 'some_conversation_id', | |
| 'user_id': '12345', | |
| 'created_at': '2025-01-01T00:00:00+00:00', | |
| 'last_updated_at': '2025-01-01T00:01:00+00:00', | |
| } | |
| ), | |
| ) | |
| with patch( | |
| 'openhands.storage.conversation.file_conversation_store.get_file_store', | |
| MagicMock(return_value=file_store), | |
| ): | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.conversation_manager.file_store', | |
| file_store, | |
| ): | |
| yield | |
| def test_client(): | |
| """Create a test client for the settings API.""" | |
| app = FastAPI() | |
| app.include_router(conversation_app) | |
| return TestClient(app) | |
| def create_new_test_conversation( | |
| test_request: InitSessionRequest, auth_type: AuthType | None = None | |
| ): | |
| # Create a mock UserSecrets object with the required custom_secrets attribute | |
| mock_user_secrets = MagicMock() | |
| mock_user_secrets.custom_secrets = MappingProxyType({}) | |
| return new_conversation( | |
| data=test_request, | |
| user_id='test_user', | |
| provider_tokens=MappingProxyType({'github': 'token123'}), | |
| user_secrets=mock_user_secrets, | |
| auth_type=auth_type, | |
| ) | |
| def provider_handler_mock(): | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.ProviderHandler' | |
| ) as mock_cls: | |
| mock_instance = MagicMock() | |
| mock_instance.verify_repo_provider = AsyncMock(return_value=ProviderType.GITHUB) | |
| mock_cls.return_value = mock_instance | |
| yield mock_instance | |
| async def test_search_conversations(): | |
| with _patch_store(): | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.config' | |
| ) as mock_config: | |
| mock_config.conversation_max_age_seconds = 864000 # 10 days | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.conversation_manager' | |
| ) as mock_manager: | |
| async def mock_get_running_agent_loops(*args, **kwargs): | |
| return set() | |
| async def mock_get_connections(*args, **kwargs): | |
| return {} | |
| async def get_agent_loop_info(*args, **kwargs): | |
| return [] | |
| mock_manager.get_running_agent_loops = mock_get_running_agent_loops | |
| mock_manager.get_connections = mock_get_connections | |
| mock_manager.get_agent_loop_info = get_agent_loop_info | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.datetime' | |
| ) as mock_datetime: | |
| mock_datetime.now.return_value = datetime.fromisoformat( | |
| '2025-01-01T00:00:00+00:00' | |
| ) | |
| mock_datetime.fromisoformat = datetime.fromisoformat | |
| mock_datetime.timezone = timezone | |
| # Mock the conversation store | |
| mock_store = MagicMock() | |
| mock_store.search = AsyncMock( | |
| return_value=ConversationInfoResultSet( | |
| results=[ | |
| ConversationMetadata( | |
| conversation_id='some_conversation_id', | |
| title='Some ServerConversation', | |
| created_at=datetime.fromisoformat( | |
| '2025-01-01T00:00:00+00:00' | |
| ), | |
| last_updated_at=datetime.fromisoformat( | |
| '2025-01-01T00:01:00+00:00' | |
| ), | |
| selected_repository='foobar', | |
| user_id='12345', | |
| ) | |
| ] | |
| ) | |
| ) | |
| result_set = await search_conversations( | |
| page_id=None, | |
| limit=20, | |
| conversation_store=mock_store, | |
| ) | |
| expected = ConversationInfoResultSet( | |
| results=[ | |
| ConversationInfo( | |
| conversation_id='some_conversation_id', | |
| title='Some ServerConversation', | |
| created_at=datetime.fromisoformat( | |
| '2025-01-01T00:00:00+00:00' | |
| ), | |
| last_updated_at=datetime.fromisoformat( | |
| '2025-01-01T00:01:00+00:00' | |
| ), | |
| status=ConversationStatus.STOPPED, | |
| selected_repository='foobar', | |
| num_connections=0, | |
| url=None, | |
| ) | |
| ] | |
| ) | |
| assert result_set == expected | |
| async def test_get_conversation(): | |
| with _patch_store(): | |
| # Mock the conversation store | |
| mock_store = MagicMock() | |
| mock_store.get_metadata = AsyncMock( | |
| return_value=ConversationMetadata( | |
| conversation_id='some_conversation_id', | |
| title='Some ServerConversation', | |
| created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'), | |
| last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'), | |
| selected_repository='foobar', | |
| user_id='12345', | |
| ) | |
| ) | |
| # Mock the conversation manager | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.conversation_manager' | |
| ) as mock_manager: | |
| mock_manager.is_agent_loop_running = AsyncMock(return_value=False) | |
| mock_manager.get_connections = AsyncMock(return_value={}) | |
| mock_manager.get_agent_loop_info = AsyncMock(return_value=[]) | |
| conversation = await get_conversation( | |
| 'some_conversation_id', conversation_store=mock_store | |
| ) | |
| expected = ConversationInfo( | |
| conversation_id='some_conversation_id', | |
| title='Some ServerConversation', | |
| created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'), | |
| last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'), | |
| status=ConversationStatus.STOPPED, | |
| selected_repository='foobar', | |
| num_connections=0, | |
| url=None, | |
| ) | |
| assert conversation == expected | |
| async def test_get_missing_conversation(): | |
| with _patch_store(): | |
| # Mock the conversation store | |
| mock_store = MagicMock() | |
| mock_store.get_metadata = AsyncMock(side_effect=FileNotFoundError) | |
| assert ( | |
| await get_conversation( | |
| 'no_such_conversation', conversation_store=mock_store | |
| ) | |
| is None | |
| ) | |
| async def test_new_conversation_success(provider_handler_mock): | |
| """Test successful creation of a new conversation.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function directly | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to return a conversation ID | |
| mock_create_conversation.return_value = MagicMock( | |
| conversation_id='test_conversation_id', | |
| url='https://my-conversation.com', | |
| session_api_key=None, | |
| status=ConversationStatus.RUNNING, | |
| ) | |
| test_request = InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| initial_user_msg='Hello, agent!', | |
| image_urls=['https://example.com/image.jpg'], | |
| ) | |
| # Call new_conversation | |
| response = await create_new_test_conversation(test_request) | |
| # Verify the response | |
| assert isinstance(response, ConversationResponse) | |
| assert response.status == 'ok' | |
| # Don't check the exact conversation_id as it's now generated dynamically | |
| assert response.conversation_id is not None | |
| assert isinstance(response.conversation_id, str) | |
| # Verify that create_new_conversation was called with the correct arguments | |
| mock_create_conversation.assert_called_once() | |
| call_args = mock_create_conversation.call_args[1] | |
| assert call_args['user_id'] == 'test_user' | |
| assert call_args['selected_repository'] == 'test/repo' | |
| assert call_args['selected_branch'] == 'main' | |
| assert call_args['initial_user_msg'] == 'Hello, agent!' | |
| assert call_args['image_urls'] == ['https://example.com/image.jpg'] | |
| assert call_args['conversation_trigger'] == ConversationTrigger.GUI | |
| async def test_new_conversation_with_suggested_task(provider_handler_mock): | |
| """Test creating a new conversation with a suggested task.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function directly | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to return a conversation ID | |
| mock_create_conversation.return_value = MagicMock( | |
| conversation_id='test_conversation_id', | |
| url='https://my-conversation.com', | |
| session_api_key=None, | |
| status=ConversationStatus.RUNNING, | |
| ) | |
| # Mock SuggestedTask.get_prompt_for_task | |
| with patch( | |
| 'openhands.integrations.service_types.SuggestedTask.get_prompt_for_task' | |
| ) as mock_get_prompt: | |
| mock_get_prompt.return_value = ( | |
| 'Please fix the failing checks in PR #123' | |
| ) | |
| test_task = SuggestedTask( | |
| git_provider=ProviderType.GITHUB, | |
| task_type=TaskType.FAILING_CHECKS, | |
| repo='test/repo', | |
| issue_number=123, | |
| title='Fix failing checks', | |
| ) | |
| test_request = InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| suggested_task=test_task, | |
| ) | |
| # Call new_conversation | |
| response = await create_new_test_conversation(test_request) | |
| # Verify the response | |
| assert isinstance(response, ConversationResponse) | |
| assert response.status == 'ok' | |
| # Don't check the exact conversation_id as it's now generated dynamically | |
| assert response.conversation_id is not None | |
| assert isinstance(response.conversation_id, str) | |
| # Verify that create_new_conversation was called with the correct arguments | |
| mock_create_conversation.assert_called_once() | |
| call_args = mock_create_conversation.call_args[1] | |
| assert call_args['user_id'] == 'test_user' | |
| assert call_args['selected_repository'] == 'test/repo' | |
| assert call_args['selected_branch'] == 'main' | |
| assert ( | |
| call_args['initial_user_msg'] | |
| == 'Please fix the failing checks in PR #123' | |
| ) | |
| assert ( | |
| call_args['conversation_trigger'] | |
| == ConversationTrigger.SUGGESTED_TASK | |
| ) | |
| # Verify that get_prompt_for_task was called | |
| mock_get_prompt.assert_called_once() | |
| async def test_new_conversation_missing_settings(provider_handler_mock): | |
| """Test creating a new conversation when settings are missing.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function to raise MissingSettingsError | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to raise MissingSettingsError | |
| mock_create_conversation.side_effect = MissingSettingsError( | |
| 'Settings not found' | |
| ) | |
| test_request = InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| initial_user_msg='Hello, agent!', | |
| ) | |
| # Call new_conversation | |
| response = await create_new_test_conversation(test_request) | |
| # Verify the response | |
| assert isinstance(response, JSONResponse) | |
| assert response.status_code == 400 | |
| assert 'Settings not found' in response.body.decode('utf-8') | |
| assert 'CONFIGURATION$SETTINGS_NOT_FOUND' in response.body.decode('utf-8') | |
| async def test_new_conversation_invalid_session_api_key(provider_handler_mock): | |
| """Test creating a new conversation with an invalid API key.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function to raise LLMAuthenticationError | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to raise LLMAuthenticationError | |
| mock_create_conversation.side_effect = LLMAuthenticationError( | |
| 'Error authenticating with the LLM provider. Please check your API key' | |
| ) | |
| test_request = InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| initial_user_msg='Hello, agent!', | |
| ) | |
| # Call new_conversation | |
| response = await create_new_test_conversation(test_request) | |
| # Verify the response | |
| assert isinstance(response, JSONResponse) | |
| assert response.status_code == 400 | |
| assert 'Error authenticating with the LLM provider' in response.body.decode( | |
| 'utf-8' | |
| ) | |
| assert 'STATUS$ERROR_LLM_AUTHENTICATION' in response.body.decode('utf-8') | |
| async def test_delete_conversation(): | |
| with _patch_store(): | |
| # Mock the ConversationStoreImpl.get_instance | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance' | |
| ) as mock_get_instance: | |
| # Create a mock conversation store | |
| mock_store = MagicMock() | |
| # Set up the mock to return metadata and then delete it | |
| mock_store.get_metadata = AsyncMock( | |
| return_value=ConversationMetadata( | |
| conversation_id='some_conversation_id', | |
| title='Some ServerConversation', | |
| created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'), | |
| last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'), | |
| selected_repository='foobar', | |
| user_id='12345', | |
| ) | |
| ) | |
| mock_store.delete_metadata = AsyncMock() | |
| # Return the mock store from get_instance | |
| mock_get_instance.return_value = mock_store | |
| # Mock the conversation manager | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.conversation_manager' | |
| ) as mock_manager: | |
| mock_manager.is_agent_loop_running = AsyncMock(return_value=False) | |
| mock_manager.get_connections = AsyncMock(return_value={}) | |
| # Mock the runtime class | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.get_runtime_cls' | |
| ) as mock_get_runtime_cls: | |
| mock_runtime_cls = MagicMock() | |
| mock_runtime_cls.delete = AsyncMock() | |
| mock_get_runtime_cls.return_value = mock_runtime_cls | |
| # Call delete_conversation | |
| result = await delete_conversation( | |
| 'some_conversation_id', user_id='12345' | |
| ) | |
| # Verify the result | |
| assert result is True | |
| # Verify that delete_metadata was called | |
| mock_store.delete_metadata.assert_called_once_with( | |
| 'some_conversation_id' | |
| ) | |
| # Verify that runtime.delete was called | |
| mock_runtime_cls.delete.assert_called_once_with( | |
| 'some_conversation_id' | |
| ) | |
| async def test_new_conversation_with_bearer_auth(provider_handler_mock): | |
| """Test creating a new conversation with bearer authentication.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to return a conversation ID | |
| mock_create_conversation.return_value = MagicMock( | |
| conversation_id='test_conversation_id', | |
| url='https://my-conversation.com', | |
| session_api_key=None, | |
| status=ConversationStatus.RUNNING, | |
| ) | |
| # Create the request object | |
| test_request = InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| initial_user_msg='Hello, agent!', | |
| ) | |
| # Call new_conversation with auth_type=BEARER | |
| response = await create_new_test_conversation(test_request, AuthType.BEARER) | |
| # Verify the response | |
| assert isinstance(response, ConversationResponse) | |
| assert response.status == 'ok' | |
| # Verify that create_new_conversation was called with REMOTE_API_KEY trigger | |
| mock_create_conversation.assert_called_once() | |
| call_args = mock_create_conversation.call_args[1] | |
| assert ( | |
| call_args['conversation_trigger'] == ConversationTrigger.REMOTE_API_KEY | |
| ) | |
| async def test_new_conversation_with_null_repository(): | |
| """Test creating a new conversation with null repository.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to return a conversation ID | |
| mock_create_conversation.return_value = MagicMock( | |
| conversation_id='test_conversation_id', | |
| url='https://my-conversation.com', | |
| session_api_key=None, | |
| status=ConversationStatus.RUNNING, | |
| ) | |
| # Create the request object with null repository | |
| test_request = InitSessionRequest( | |
| repository=None, # Explicitly set to None | |
| selected_branch=None, | |
| initial_user_msg='Hello, agent!', | |
| ) | |
| # Call new_conversation | |
| response = await create_new_test_conversation(test_request) | |
| # Verify the response | |
| assert isinstance(response, ConversationResponse) | |
| assert response.status == 'ok' | |
| # Verify that create_new_conversation was called with None repository | |
| mock_create_conversation.assert_called_once() | |
| call_args = mock_create_conversation.call_args[1] | |
| assert call_args['selected_repository'] is None | |
| async def test_new_conversation_with_provider_authentication_error( | |
| provider_handler_mock, | |
| ): | |
| provider_handler_mock.verify_repo_provider = AsyncMock( | |
| side_effect=AuthenticationError('auth error') | |
| ) | |
| """Test creating a new conversation when provider authentication fails.""" | |
| with _patch_store(): | |
| # Mock the create_new_conversation function | |
| with patch( | |
| 'openhands.server.routes.manage_conversations.create_new_conversation' | |
| ) as mock_create_conversation: | |
| # Set up the mock to return a conversation ID | |
| mock_create_conversation.return_value = 'test_conversation_id' | |
| # Create the request object | |
| test_request = InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| initial_user_msg='Hello, agent!', | |
| ) | |
| # Call new_conversation | |
| response = await create_new_test_conversation(test_request) | |
| # Verify the response | |
| assert isinstance(response, JSONResponse) | |
| assert response.status_code == 400 | |
| assert json.loads(response.body.decode('utf-8')) == { | |
| 'status': 'error', | |
| 'message': 'auth error', | |
| 'msg_id': 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR', | |
| } | |
| # Verify that verify_repo_provider was called with the repository | |
| provider_handler_mock.verify_repo_provider.assert_called_once_with( | |
| 'test/repo', None | |
| ) | |
| # Verify that create_new_conversation was not called | |
| mock_create_conversation.assert_not_called() | |
| async def test_new_conversation_with_unsupported_params(): | |
| """Test that unsupported parameters are rejected.""" | |
| # Create a test request with an unsupported parameter | |
| with _patch_store(): | |
| # Create a direct instance of InitSessionRequest to test validation | |
| with pytest.raises(Exception) as excinfo: | |
| # This should raise a validation error because of the extra parameter | |
| InitSessionRequest( | |
| repository='test/repo', | |
| selected_branch='main', | |
| initial_user_msg='Hello, agent!', | |
| unsupported_param='unsupported param', # This should cause validation to fail | |
| ) | |
| # Verify that the error message mentions the unsupported parameter | |
| assert 'Extra inputs are not permitted' in str(excinfo.value) | |
| assert 'unsupported_param' in str(excinfo.value) | |