diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index f177e1f753..532937f98a 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -79,6 +79,7 @@ export interface Conversation { conversation_version?: "V0" | "V1"; sub_conversation_ids?: string[]; public?: boolean; + sandbox_id?: string | null; } export interface ResultSet { diff --git a/openhands/server/data_models/conversation_info.py b/openhands/server/data_models/conversation_info.py index 75e1176279..db5d91eff2 100644 --- a/openhands/server/data_models/conversation_info.py +++ b/openhands/server/data_models/conversation_info.py @@ -38,3 +38,4 @@ class ConversationInfo: conversation_version: str = 'V0' sub_conversation_ids: list[str] = field(default_factory=list) public: bool | None = None + sandbox_id: str | None = None diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 968d4a81bf..eb9d359885 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -766,6 +766,7 @@ async def _get_conversation_info( url=agent_loop_info.url if agent_loop_info else None, session_api_key=getattr(agent_loop_info, 'session_api_key', None), pr_number=conversation.pr_number, + sandbox_id=None, # V0 conversations don't have sandbox_id ) except Exception as e: logger.error( @@ -1582,4 +1583,5 @@ def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo sub_id.hex for sub_id in app_conversation.sub_conversation_ids ], public=app_conversation.public, + sandbox_id=app_conversation.sandbox_id, ) diff --git a/tests/unit/server/routes/test_conversation_routes.py b/tests/unit/server/routes/test_conversation_routes.py index 3892906ed0..9b1e08bc7e 100644 --- a/tests/unit/server/routes/test_conversation_routes.py +++ b/tests/unit/server/routes/test_conversation_routes.py @@ -24,8 +24,12 @@ from openhands.app_server.app_conversation.app_conversation_service import ( AppConversationService, ) from openhands.app_server.sandbox.sandbox_models import SandboxStatus +from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent from openhands.microagent.types import MicroagentMetadata, MicroagentType +from openhands.runtime.runtime_status import RuntimeStatus +from openhands.sdk.conversation.state import ConversationExecutionStatus +from openhands.server.data_models.agent_loop_info import AgentLoopInfo from openhands.server.data_models.conversation_info import ConversationStatus from openhands.server.data_models.conversation_info_result_set import ( ConversationInfoResultSet, @@ -38,6 +42,8 @@ from openhands.server.routes.conversation import ( from openhands.server.routes.manage_conversations import ( _RESUME_GRACE_PERIOD, UpdateConversationRequest, + _get_conversation_info, + _to_conversation_info, get_conversation, search_conversations, update_conversation, @@ -54,8 +60,6 @@ from openhands.storage.data_models.conversation_metadata import ( async def test_get_microagents(): """Test the get_microagents function directly.""" # Create mock microagents - from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig - repo_microagent = RepoMicroagent( name='test_repo', content='This is a test repo microagent', @@ -1152,8 +1156,6 @@ async def test_add_message_empty_message(): @pytest.mark.asyncio async def test_create_sub_conversation_with_planning_agent(): """Test creating a sub-conversation from a parent conversation with planning agent.""" - from uuid import uuid4 - parent_conversation_id = uuid4() user_id = 'test_user_456' sandbox_id = 'test_sandbox_123' @@ -1513,8 +1515,6 @@ async def test_get_conversation_resume_status_handling( should_call_server, ): """Test get_conversation handles resume status correctly for various scenarios.""" - from openhands.sdk.conversation.state import ConversationExecutionStatus - conversation_id = uuid4() # Convert string execution_status to enum if provided @@ -1564,3 +1564,125 @@ async def test_get_conversation_resume_status_handling( ) else: mock_httpx_client.get.assert_not_called() + + +# Tests for _to_conversation_info and _get_conversation_info sandbox_id mapping + + +@pytest.mark.asyncio +async def test_to_conversation_info_maps_sandbox_id(): + """Test that _to_conversation_info correctly maps sandbox_id from V1 AppConversation.""" + conversation_id = uuid4() + test_sandbox_id = 'test-sandbox-123' + + # Create a mock V1 AppConversation with sandbox_id + mock_app_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id=test_sandbox_id, + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + conversation_url='https://sandbox.example.com/conversation', + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + title='Test Conversation', + selected_repository='test/repo', + selected_branch='main', + git_provider='github', + trigger=ConversationTrigger.GUI, + sub_conversation_ids=[], + public=False, + ) + + result = _to_conversation_info(mock_app_conversation) + + assert result is not None + assert result.conversation_id == conversation_id.hex + assert result.sandbox_id == test_sandbox_id + assert result.conversation_version == 'V1' + + +@pytest.mark.asyncio +async def test_get_conversation_info_v0_has_null_sandbox_id(): + """Test that _get_conversation_info returns null sandbox_id for V0 conversations.""" + conversation_id = 'test-conversation-v0' + + # Create mock V0 conversation metadata (no sandbox_id field) + mock_metadata = ConversationMetadata( + conversation_id=conversation_id, + user_id='test_user', + title='V0 Test Conversation', + selected_repository=None, + last_updated_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc), + ) + + # Create mock agent_loop_info + mock_agent_loop_info = AgentLoopInfo( + conversation_id=conversation_id, + runtime_status=RuntimeStatus.READY, + status=ConversationStatus.STOPPED, + event_store=None, + url=None, + session_api_key=None, + ) + + result = await _get_conversation_info( + conversation=mock_metadata, + num_connections=0, + agent_loop_info=mock_agent_loop_info, + ) + + assert result is not None + assert result.sandbox_id is None + assert result.conversation_version == 'V0' + + +@pytest.mark.asyncio +async def test_get_conversation_info_returns_all_fields(): + """Test that _get_conversation_info returns all expected fields for V0 conversations.""" + conversation_id = 'test-conversation-full' + + # Create mock V0 conversation metadata + mock_metadata = ConversationMetadata( + conversation_id=conversation_id, + user_id='test_user', + title='Full Test Conversation', + selected_repository='test/repo', + selected_branch='main', + git_provider='github', + trigger=ConversationTrigger.GUI, + last_updated_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc), + pr_number=[123], + ) + + # Create mock agent_loop_info + mock_agent_loop_info = AgentLoopInfo( + conversation_id=conversation_id, + runtime_status=RuntimeStatus.READY, + status=ConversationStatus.STOPPED, + event_store=None, + url='https://example.com/conversation', + session_api_key='test-key', + ) + + result = await _get_conversation_info( + conversation=mock_metadata, + num_connections=5, + agent_loop_info=mock_agent_loop_info, + ) + + assert result is not None + assert result.conversation_id == conversation_id + assert result.title == 'Full Test Conversation' + assert result.selected_repository == 'test/repo' + assert result.selected_branch == 'main' + assert result.git_provider == 'github' + assert result.trigger == ConversationTrigger.GUI + assert result.num_connections == 5 + assert result.url == 'https://example.com/conversation' + assert result.session_api_key == 'test-key' + assert result.pr_number == [123] + assert result.sandbox_id is None # V0 should have null sandbox_id + assert result.conversation_version == 'V0'