From d62bb81c3bd76fe4aa690e6d03681e6822cabfff Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Sun, 30 Nov 2025 13:29:13 +0700 Subject: [PATCH] feat(backend): implement API to fetch contents of PLAN.md (#11795) --- .../app_conversation_router.py | 111 ++ .../sandbox/docker_sandbox_service.py | 4 +- openhands/runtime/impl/docker/containers.py | 2 +- .../server/data_models/test_conversation.py | 944 +++++++++++++++++- 4 files changed, 1039 insertions(+), 22 deletions(-) diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index b66d998362..bf82840e96 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -1,7 +1,9 @@ """Sandboxed Conversation router for OpenHands Server.""" import asyncio +import os import sys +import tempfile from datetime import datetime from typing import Annotated, AsyncGenerator from uuid import UUID @@ -49,9 +51,21 @@ from openhands.app_server.config import ( depends_app_conversation_start_task_service, depends_db_session, depends_httpx_client, + depends_sandbox_service, + depends_sandbox_spec_service, depends_user_context, get_app_conversation_service, ) +from openhands.app_server.sandbox.sandbox_models import ( + AGENT_SERVER, + SandboxStatus, +) +from openhands.app_server.sandbox.sandbox_service import SandboxService +from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService +from openhands.app_server.utils.docker_utils import ( + replace_localhost_hostname_for_docker, +) +from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace router = APIRouter(prefix='/app-conversations', tags=['Conversations']) app_conversation_service_dependency = depends_app_conversation_service() @@ -61,6 +75,8 @@ app_conversation_start_task_service_dependency = ( user_context_dependency = depends_user_context() db_session_dependency = depends_db_session() httpx_client_dependency = depends_httpx_client() +sandbox_service_dependency = depends_sandbox_service() +sandbox_spec_service_dependency = depends_sandbox_spec_service() # Read methods @@ -289,6 +305,101 @@ async def batch_get_app_conversation_start_tasks( return start_tasks +@router.get('/{conversation_id}/file') +async def read_conversation_file( + conversation_id: UUID, + file_path: Annotated[ + str, + Query(title='Path to the file to read within the sandbox workspace'), + ] = '/workspace/project/PLAN.md', + app_conversation_service: AppConversationService = ( + app_conversation_service_dependency + ), + sandbox_service: SandboxService = sandbox_service_dependency, + sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency, +) -> str: + """Read a file from a specific conversation's sandbox workspace. + + Returns the content of the file at the specified path if it exists, otherwise returns an empty string. + + Args: + conversation_id: The UUID of the conversation + file_path: Path to the file to read within the sandbox workspace + + Returns: + The content of the file or an empty string if the file doesn't exist + """ + # Get the conversation info + conversation = await app_conversation_service.get_app_conversation(conversation_id) + if not conversation: + return '' + + # Get the sandbox info + sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id) + if not sandbox or sandbox.status != SandboxStatus.RUNNING: + return '' + + # Get the sandbox spec to find the working directory + sandbox_spec = await sandbox_spec_service.get_sandbox_spec(sandbox.sandbox_spec_id) + if not sandbox_spec: + return '' + + # Get the agent server URL + if not sandbox.exposed_urls: + return '' + + agent_server_url = None + for exposed_url in sandbox.exposed_urls: + if exposed_url.name == AGENT_SERVER: + agent_server_url = exposed_url.url + break + + if not agent_server_url: + return '' + + agent_server_url = replace_localhost_hostname_for_docker(agent_server_url) + + # Create remote workspace + remote_workspace = AsyncRemoteWorkspace( + host=agent_server_url, + api_key=sandbox.session_api_key, + working_dir=sandbox_spec.working_dir, + ) + + # Read the file at the specified path + temp_file_path = None + try: + # Create a temporary file path to download the remote file + with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as temp_file: + temp_file_path = temp_file.name + + # Download the file from remote system + result = await remote_workspace.file_download( + source_path=file_path, + destination_path=temp_file_path, + ) + + if result.success: + # Read the content from the temporary file + with open(temp_file_path, 'rb') as f: + content = f.read() + # Decode bytes to string + return content.decode('utf-8') + except Exception: + # If there's any error reading the file, return empty string + pass + finally: + # Clean up the temporary file + if temp_file_path: + try: + os.unlink(temp_file_path) + except Exception: + # Ignore errors during cleanup + pass + + return '' + + async def _consume_remaining( async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient ): diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index ff6e0669ae..d7fe0b726d 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -217,7 +217,9 @@ class DockerSandboxService(SandboxService): sandboxes = [] for container in all_containers: - if container.name.startswith(self.container_name_prefix): + if container.name and container.name.startswith( + self.container_name_prefix + ): sandbox_info = await self._container_to_checked_sandbox_info( container ) diff --git a/openhands/runtime/impl/docker/containers.py b/openhands/runtime/impl/docker/containers.py index 25764b0274..32a5ba1353 100644 --- a/openhands/runtime/impl/docker/containers.py +++ b/openhands/runtime/impl/docker/containers.py @@ -7,7 +7,7 @@ def stop_all_containers(prefix: str) -> None: containers = docker_client.containers.list(all=True) for container in containers: try: - if container.name.startswith(prefix): + if container.name and container.name.startswith(prefix): container.stop() except docker.errors.APIError: pass diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index 98cf9b09ea..cf12e83361 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -15,6 +15,9 @@ from openhands.app_server.app_conversation.app_conversation_models import ( AppConversation, AppConversationPage, ) +from openhands.app_server.app_conversation.app_conversation_router import ( + read_conversation_file, +) from openhands.app_server.app_conversation.live_status_app_conversation_service import ( LiveStatusAppConversationService, ) @@ -27,6 +30,7 @@ from openhands.app_server.sandbox.sandbox_models import ( SandboxInfo, SandboxStatus, ) +from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo from openhands.app_server.user.user_context import UserContext from openhands.integrations.service_types import ( AuthenticationError, @@ -37,6 +41,10 @@ from openhands.integrations.service_types import ( ) from openhands.runtime.runtime_status import RuntimeStatus from openhands.sdk.conversation.state import ConversationExecutionStatus +from openhands.sdk.workspace.models import FileOperationResult +from openhands.sdk.workspace.remote.async_remote_workspace import ( + AsyncRemoteWorkspace, +) from openhands.server.data_models.conversation_info import ConversationInfo from openhands.server.data_models.conversation_info_result_set import ( ConversationInfoResultSet, @@ -980,14 +988,6 @@ async def test_delete_conversation(): @pytest.mark.asyncio async def test_delete_v1_conversation_success(): """Test successful deletion of a V1 conversation.""" - from uuid import uuid4 - - from openhands.app_server.app_conversation.app_conversation_models import ( - AppConversation, - ) - from openhands.app_server.sandbox.sandbox_models import SandboxStatus - from openhands.sdk.conversation.state import ConversationExecutionStatus - conversation_uuid = uuid4() conversation_id = str(conversation_uuid) @@ -1060,8 +1060,6 @@ async def test_delete_v1_conversation_success(): @pytest.mark.asyncio async def test_delete_v1_conversation_not_found(): """Test deletion of a V1 conversation that doesn't exist.""" - from uuid import uuid4 - conversation_uuid = uuid4() conversation_id = str(conversation_uuid) @@ -1198,8 +1196,6 @@ async def test_delete_v1_conversation_invalid_uuid(): @pytest.mark.asyncio async def test_delete_v1_conversation_service_error(): """Test deletion when app conversation service raises an error.""" - from uuid import uuid4 - conversation_uuid = uuid4() conversation_id = str(conversation_uuid) @@ -1293,14 +1289,6 @@ async def test_delete_v1_conversation_service_error(): @pytest.mark.asyncio async def test_delete_v1_conversation_with_agent_server(): """Test V1 conversation deletion with agent server integration.""" - from uuid import uuid4 - - from openhands.app_server.app_conversation.app_conversation_models import ( - AppConversation, - ) - from openhands.app_server.sandbox.sandbox_models import SandboxStatus - from openhands.sdk.conversation.state import ConversationExecutionStatus - conversation_uuid = uuid4() conversation_id = str(conversation_uuid) @@ -2475,3 +2463,919 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error(): assert sub2_uuid in delete_calls assert parent_uuid in delete_calls assert sub1_uuid not in delete_calls # Failed before deletion + + +@pytest.mark.asyncio +async def test_read_conversation_file_success(): + """Test successfully retrieving file content from conversation workspace.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + file_content = '# Project Plan\n\n## Phase 1\n- Task 1\n- Task 2\n' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000) + ], + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Mock tempfile and file operations + temp_file_path = '/tmp/test_file_12345' + mock_file_result = FileOperationResult( + success=True, + source_path=file_path, + destination_path=temp_file_path, + file_size=len(file_content.encode('utf-8')), + ) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace' + ) as mock_workspace_class: + mock_workspace = MagicMock(spec=AsyncRemoteWorkspace) + mock_workspace.file_download = AsyncMock(return_value=mock_file_result) + mock_workspace_class.return_value = mock_workspace + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile' + ) as mock_tempfile: + mock_temp_file = MagicMock() + mock_temp_file.name = temp_file_path + mock_tempfile.return_value.__enter__ = MagicMock( + return_value=mock_temp_file + ) + mock_tempfile.return_value.__exit__ = MagicMock(return_value=None) + + with patch('builtins.open', create=True) as mock_open: + mock_file_handle = MagicMock() + mock_file_handle.read.return_value = file_content.encode('utf-8') + mock_open.return_value.__enter__ = MagicMock( + return_value=mock_file_handle + ) + mock_open.return_value.__exit__ = MagicMock(return_value=None) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.os.unlink' + ) as mock_unlink: + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == file_content + + # Verify services were called correctly + mock_app_conversation_service.get_app_conversation.assert_called_once_with( + conversation_id + ) + mock_sandbox_service.get_sandbox.assert_called_once_with( + 'test-sandbox-id' + ) + mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with( + 'test-spec-id' + ) + + # Verify workspace was created and file_download was called + mock_workspace_class.assert_called_once() + mock_workspace.file_download.assert_called_once_with( + source_path=file_path, + destination_path=temp_file_path, + ) + + # Verify file was read and cleaned up + mock_open.assert_called_once_with(temp_file_path, 'rb') + mock_unlink.assert_called_once_with(temp_file_path) + + +@pytest.mark.asyncio +async def test_read_conversation_file_different_path(): + """Test successfully retrieving file content from a different file path.""" + conversation_id = uuid4() + file_path = '/workspace/project/src/main.py' + file_content = 'def main():\n print("Hello, World!")\n' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000) + ], + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Mock tempfile and file operations + temp_file_path = '/tmp/test_file_67890' + mock_file_result = FileOperationResult( + success=True, + source_path=file_path, + destination_path=temp_file_path, + file_size=len(file_content.encode('utf-8')), + ) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace' + ) as mock_workspace_class: + mock_workspace = MagicMock(spec=AsyncRemoteWorkspace) + mock_workspace.file_download = AsyncMock(return_value=mock_file_result) + mock_workspace_class.return_value = mock_workspace + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile' + ) as mock_tempfile: + mock_temp_file = MagicMock() + mock_temp_file.name = temp_file_path + mock_tempfile.return_value.__enter__ = MagicMock( + return_value=mock_temp_file + ) + mock_tempfile.return_value.__exit__ = MagicMock(return_value=None) + + with patch('builtins.open', create=True) as mock_open: + mock_file_handle = MagicMock() + mock_file_handle.read.return_value = file_content.encode('utf-8') + mock_open.return_value.__enter__ = MagicMock( + return_value=mock_file_handle + ) + mock_open.return_value.__exit__ = MagicMock(return_value=None) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.os.unlink' + ) as mock_unlink: + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == file_content + + # Verify workspace was created and file_download was called + mock_workspace_class.assert_called_once() + mock_workspace.file_download.assert_called_once_with( + source_path=file_path, + destination_path=temp_file_path, + ) + + # Verify file was read and cleaned up + mock_open.assert_called_once_with(temp_file_path, 'rb') + mock_unlink.assert_called_once_with(temp_file_path) + + +@pytest.mark.asyncio +async def test_read_conversation_file_conversation_not_found(): + """Test when conversation doesn't exist.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock(return_value=None) + + mock_sandbox_service = MagicMock() + mock_sandbox_spec_service = MagicMock() + + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == '' + + # Verify only conversation service was called + mock_app_conversation_service.get_app_conversation.assert_called_once_with( + conversation_id + ) + mock_sandbox_service.get_sandbox.assert_not_called() + mock_sandbox_spec_service.get_sandbox_spec.assert_not_called() + + +@pytest.mark.asyncio +async def test_read_conversation_file_sandbox_not_found(): + """Test when sandbox doesn't exist.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=None) + + mock_sandbox_spec_service = MagicMock() + + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == '' + + # Verify services were called + mock_app_conversation_service.get_app_conversation.assert_called_once_with( + conversation_id + ) + mock_sandbox_service.get_sandbox.assert_called_once_with('test-sandbox-id') + mock_sandbox_spec_service.get_sandbox_spec.assert_not_called() + + +@pytest.mark.asyncio +async def test_read_conversation_file_sandbox_not_running(): + """Test when sandbox is not in RUNNING status.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.PAUSED, + execution_status=None, + session_api_key=None, + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.PAUSED, + session_api_key=None, + exposed_urls=None, + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == '' + + # Verify services were called + mock_app_conversation_service.get_app_conversation.assert_called_once_with( + conversation_id + ) + mock_sandbox_service.get_sandbox.assert_called_once_with('test-sandbox-id') + mock_sandbox_spec_service.get_sandbox_spec.assert_not_called() + + +@pytest.mark.asyncio +async def test_read_conversation_file_sandbox_spec_not_found(): + """Test when sandbox spec doesn't exist.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000) + ], + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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=None) + + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == '' + + # Verify services were called + mock_app_conversation_service.get_app_conversation.assert_called_once_with( + conversation_id + ) + mock_sandbox_service.get_sandbox.assert_called_once_with('test-sandbox-id') + mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with('test-spec-id') + + +@pytest.mark.asyncio +async def test_read_conversation_file_no_exposed_urls(): + """Test when sandbox has no exposed URLs.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox with no exposed URLs + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=None, + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == '' + + +@pytest.mark.asyncio +async def test_read_conversation_file_no_agent_server_url(): + """Test when sandbox has exposed URLs but no AGENT_SERVER.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox with exposed URLs but no AGENT_SERVER + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name='OTHER_SERVICE', url='http://other:9000', port=9000) + ], + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result + assert result == '' + + +@pytest.mark.asyncio +async def test_read_conversation_file_file_not_found(): + """Test when file doesn't exist.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000) + ], + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Mock tempfile and file operations for file not found + temp_file_path = '/tmp/test_file_not_found' + mock_file_result = FileOperationResult( + success=False, + source_path=file_path, + destination_path=temp_file_path, + error=f'File not found: {file_path}', + ) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace' + ) as mock_workspace_class: + mock_workspace = MagicMock(spec=AsyncRemoteWorkspace) + mock_workspace.file_download = AsyncMock(return_value=mock_file_result) + mock_workspace_class.return_value = mock_workspace + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile' + ) as mock_tempfile: + mock_temp_file = MagicMock() + mock_temp_file.name = temp_file_path + mock_tempfile.return_value.__enter__ = MagicMock( + return_value=mock_temp_file + ) + mock_tempfile.return_value.__exit__ = MagicMock(return_value=None) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.os.unlink' + ) as mock_unlink: + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result (empty string when file_download fails) + assert result == '' + + # Verify cleanup still happens + mock_unlink.assert_called_once_with(temp_file_path) + + +@pytest.mark.asyncio +async def test_read_conversation_file_empty_file(): + """Test when file exists but is empty.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000) + ], + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Mock tempfile and file operations for empty file + temp_file_path = '/tmp/test_file_empty' + empty_content = '' + mock_file_result = FileOperationResult( + success=True, + source_path=file_path, + destination_path=temp_file_path, + file_size=0, + ) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace' + ) as mock_workspace_class: + mock_workspace = MagicMock(spec=AsyncRemoteWorkspace) + mock_workspace.file_download = AsyncMock(return_value=mock_file_result) + mock_workspace_class.return_value = mock_workspace + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile' + ) as mock_tempfile: + mock_temp_file = MagicMock() + mock_temp_file.name = temp_file_path + mock_tempfile.return_value.__enter__ = MagicMock( + return_value=mock_temp_file + ) + mock_tempfile.return_value.__exit__ = MagicMock(return_value=None) + + with patch('builtins.open', create=True) as mock_open: + mock_file_handle = MagicMock() + mock_file_handle.read.return_value = empty_content.encode('utf-8') + mock_open.return_value.__enter__ = MagicMock( + return_value=mock_file_handle + ) + mock_open.return_value.__exit__ = MagicMock(return_value=None) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.os.unlink' + ) as mock_unlink: + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result (empty string when file is empty) + assert result == '' + + # Verify cleanup happens + mock_unlink.assert_called_once_with(temp_file_path) + + +@pytest.mark.asyncio +async def test_read_conversation_file_command_exception(): + """Test when command execution raises an exception.""" + conversation_id = uuid4() + file_path = '/workspace/project/PLAN.md' + + # Mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test_user', + sandbox_id='test-sandbox-id', + title='Test Conversation', + sandbox_status=SandboxStatus.RUNNING, + execution_status=ConversationExecutionStatus.RUNNING, + session_api_key='test-api-key', + selected_repository='test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + trigger=ConversationTrigger.GUI, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + # Mock sandbox + mock_sandbox = SandboxInfo( + id='test-sandbox-id', + created_by_user_id='test_user', + sandbox_spec_id='test-spec-id', + status=SandboxStatus.RUNNING, + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000) + ], + ) + + # Mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id='test-spec-id', + command=None, + working_dir='/workspace', + created_at=datetime.now(timezone.utc), + ) + + # Mock services + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + 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 + ) + + # Mock tempfile and file operations for exception case + temp_file_path = '/tmp/test_file_exception' + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace' + ) as mock_workspace_class: + mock_workspace = MagicMock(spec=AsyncRemoteWorkspace) + mock_workspace.file_download = AsyncMock( + side_effect=Exception('Connection timeout') + ) + mock_workspace_class.return_value = mock_workspace + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile' + ) as mock_tempfile: + mock_temp_file = MagicMock() + mock_temp_file.name = temp_file_path + mock_tempfile.return_value.__enter__ = MagicMock( + return_value=mock_temp_file + ) + mock_tempfile.return_value.__exit__ = MagicMock(return_value=None) + + with patch( + 'openhands.app_server.app_conversation.app_conversation_router.os.unlink' + ) as mock_unlink: + # Call the endpoint + result = await read_conversation_file( + conversation_id=conversation_id, + file_path=file_path, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Verify result (empty string on exception) + assert result == '' + + # Verify cleanup still happens even on exception + mock_unlink.assert_called_once_with(temp_file_path)