feat(backend): implement API to fetch contents of PLAN.md (#11795)

This commit is contained in:
Hiep Le 2025-11-30 13:29:13 +07:00 committed by GitHub
parent 156d0686c4
commit d62bb81c3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1039 additions and 22 deletions

View File

@ -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
):

View File

@ -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
)

View File

@ -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

View File

@ -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)