diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 05ce06cf90..5e348e78d2 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -22,6 +22,7 @@ class TaskType(str, Enum): UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS' OPEN_ISSUE = 'OPEN_ISSUE' OPEN_PR = 'OPEN_PR' + CREATE_MICROAGENT = 'CREATE_MICROAGENT' class OwnerType(str, Enum): @@ -98,6 +99,12 @@ class SuggestedTask(BaseModel): return template.render(issue_number=issue_number, repo=repo, **terms) +class CreateMicroagent(BaseModel): + repo: str + git_provider: ProviderType | None = None + title: str | None = None + + class User(BaseModel): id: str login: str diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 8f85634061..bce9eb6681 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -27,6 +27,7 @@ from openhands.integrations.provider import ( ) from openhands.integrations.service_types import ( AuthenticationError, + CreateMicroagent, ProviderType, SuggestedTask, ) @@ -84,6 +85,7 @@ class InitSessionRequest(BaseModel): image_urls: list[str] | None = None replay_json: str | None = None suggested_task: SuggestedTask | None = None + create_microagent: CreateMicroagent | None = None conversation_instructions: str | None = None # Only nested runtimes require the ability to specify a conversation id, and it could be a security risk if os.getenv('ALLOW_SET_CONVERSATION_ID', '0') == '1': @@ -123,6 +125,7 @@ async def new_conversation( image_urls = data.image_urls or [] replay_json = data.replay_json suggested_task = data.suggested_task + create_microagent = data.create_microagent git_provider = data.git_provider conversation_instructions = data.conversation_instructions @@ -131,6 +134,13 @@ async def new_conversation( if suggested_task: initial_user_msg = suggested_task.get_prompt_for_task() conversation_trigger = ConversationTrigger.SUGGESTED_TASK + elif create_microagent: + conversation_trigger = ConversationTrigger.MICROAGENT_MANAGEMENT + # Set repository and git_provider from create_microagent if not already set + if not repository and create_microagent.repo: + repository = create_microagent.repo + if not git_provider and create_microagent.git_provider: + git_provider = create_microagent.git_provider if auth_type == AuthType.BEARER: conversation_trigger = ConversationTrigger.REMOTE_API_KEY diff --git a/openhands/storage/data_models/conversation_metadata.py b/openhands/storage/data_models/conversation_metadata.py index 8adea2835a..464f1dac07 100644 --- a/openhands/storage/data_models/conversation_metadata.py +++ b/openhands/storage/data_models/conversation_metadata.py @@ -11,6 +11,7 @@ class ConversationTrigger(Enum): SUGGESTED_TASK = 'suggested_task' REMOTE_API_KEY = 'openhands_api' SLACK = 'slack' + MICROAGENT_MANAGEMENT = 'microagent_management' @dataclass diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py index 9b6ab18132..b19ad9d855 100644 --- a/tests/unit/test_conversation.py +++ b/tests/unit/test_conversation.py @@ -11,6 +11,7 @@ from fastapi.testclient import TestClient from openhands.integrations.service_types import ( AuthenticationError, + CreateMicroagent, ProviderType, SuggestedTask, TaskType, @@ -608,3 +609,174 @@ async def test_new_conversation_with_unsupported_params(): # 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) + + +@pytest.mark.asyncio +async def test_new_conversation_with_create_microagent(provider_handler_mock): + """Test creating a new conversation with a CreateMicroagent object.""" + 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, + ) + + # Create the CreateMicroagent object + create_microagent = CreateMicroagent( + repo='test/repo', + git_provider=ProviderType.GITHUB, + title='Create a new microagent', + ) + + test_request = InitSessionRequest( + repository=None, # Not set in request, should be set from create_microagent + selected_branch='main', + initial_user_msg='Hello, agent!', + create_microagent=create_microagent, + ) + + # Call new_conversation + response = await create_new_test_conversation(test_request) + + # Verify the response + assert isinstance(response, ConversationResponse) + assert response.status == 'ok' + 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' + ) # Should be set from create_microagent + assert call_args['selected_branch'] == 'main' + assert call_args['initial_user_msg'] == 'Hello, agent!' + assert ( + call_args['conversation_trigger'] + == ConversationTrigger.MICROAGENT_MANAGEMENT + ) + assert ( + call_args['git_provider'] == ProviderType.GITHUB + ) # Should be set from create_microagent + + +@pytest.mark.asyncio +async def test_new_conversation_with_create_microagent_repository_override( + provider_handler_mock, +): + """Test creating a new conversation with CreateMicroagent when repository is already set.""" + 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, + ) + + # Create the CreateMicroagent object + create_microagent = CreateMicroagent( + repo='microagent/repo', + git_provider=ProviderType.GITLAB, + title='Create a new microagent', + ) + + test_request = InitSessionRequest( + repository='existing/repo', # Already set in request + selected_branch='main', + initial_user_msg='Hello, agent!', + create_microagent=create_microagent, + ) + + # Call new_conversation + response = await create_new_test_conversation(test_request) + + # Verify the response + assert isinstance(response, ConversationResponse) + assert response.status == 'ok' + 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'] == 'existing/repo' + ) # Should keep existing value + assert call_args['selected_branch'] == 'main' + assert call_args['initial_user_msg'] == 'Hello, agent!' + assert ( + call_args['conversation_trigger'] + == ConversationTrigger.MICROAGENT_MANAGEMENT + ) + assert ( + call_args['git_provider'] == ProviderType.GITLAB + ) # Should be set from create_microagent + + +@pytest.mark.asyncio +async def test_new_conversation_with_create_microagent_minimal(provider_handler_mock): + """Test creating a new conversation with minimal CreateMicroagent object (only repo field).""" + 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, + ) + + # Create the CreateMicroagent object with only required field + create_microagent = CreateMicroagent( + repo='minimal/repo', + ) + + test_request = InitSessionRequest( + repository=None, + selected_branch='main', + initial_user_msg='Hello, agent!', + create_microagent=create_microagent, + ) + + # Call new_conversation + response = await create_new_test_conversation(test_request) + + # Verify the response + assert isinstance(response, ConversationResponse) + assert response.status == 'ok' + 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'] == 'minimal/repo' + ) # Should be set from create_microagent + assert call_args['selected_branch'] == 'main' + assert call_args['initial_user_msg'] == 'Hello, agent!' + assert ( + call_args['conversation_trigger'] + == ConversationTrigger.MICROAGENT_MANAGEMENT + ) + assert ( + call_args['git_provider'] is None + ) # Should remain None since not set in create_microagent