feat: Load workspace hooks for V1 conversations and add hooks viewer UI (#12773)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: Alona King <alona@all-hands.dev>
This commit is contained in:
Xingyao Wang
2026-03-17 00:55:23 +08:00
committed by GitHub
parent a0e777503e
commit 00daaa41d3
37 changed files with 2452 additions and 84 deletions

View File

@@ -0,0 +1,293 @@
"""Unit tests for the V1 hooks endpoint in app_conversation_router.
This module tests the GET /{conversation_id}/hooks endpoint functionality.
"""
from unittest.mock import AsyncMock, MagicMock, Mock
from uuid import uuid4
import httpx
import pytest
from fastapi import status
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
)
from openhands.app_server.app_conversation.app_conversation_router import (
get_conversation_hooks,
)
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
ExposedUrl,
SandboxInfo,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
@pytest.mark.asyncio
class TestGetConversationHooks:
async def test_get_hooks_returns_hook_events(self):
conversation_id = uuid4()
sandbox_id = str(uuid4())
working_dir = '/workspace'
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
selected_repository='owner/repo',
sandbox_status=SandboxStatus.RUNNING,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000)
],
)
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir=working_dir
)
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_response = Mock()
mock_response.status_code = 200
mock_response.raise_for_status = Mock()
mock_response.json.return_value = {
'hook_config': {
'stop': [
{
'matcher': '*',
'hooks': [
{
'type': 'command',
'command': '.openhands/hooks/on_stop.sh',
'timeout': 60,
'async': True,
}
],
}
]
}
}
mock_httpx_client = AsyncMock(spec=httpx.AsyncClient)
mock_httpx_client.post = AsyncMock(return_value=mock_response)
response = await get_conversation_hooks(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
httpx_client=mock_httpx_client,
)
assert response.status_code == status.HTTP_200_OK
data = __import__('json').loads(response.body.decode('utf-8'))
assert 'hooks' in data
assert data['hooks']
assert data['hooks'][0]['event_type'] == 'stop'
assert data['hooks'][0]['matchers'][0]['matcher'] == '*'
assert data['hooks'][0]['matchers'][0]['hooks'][0]['type'] == 'command'
assert (
data['hooks'][0]['matchers'][0]['hooks'][0]['command']
== '.openhands/hooks/on_stop.sh'
)
assert data['hooks'][0]['matchers'][0]['hooks'][0]['async'] is True
assert 'async_' not in data['hooks'][0]['matchers'][0]['hooks'][0]
mock_httpx_client.post.assert_called_once()
called_url = mock_httpx_client.post.call_args[0][0]
assert called_url == 'http://agent-server:8000/api/hooks'
async def test_get_hooks_returns_502_when_agent_server_unreachable(self):
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
selected_repository=None,
sandbox_status=SandboxStatus.RUNNING,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000)
],
)
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir='/workspace'
)
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_httpx_client = AsyncMock(spec=httpx.AsyncClient)
def _raise_request_error(*args, **_kwargs):
request = httpx.Request('POST', args[0])
raise httpx.RequestError('Connection error', request=request)
mock_httpx_client.post = AsyncMock(side_effect=_raise_request_error)
response = await get_conversation_hooks(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
httpx_client=mock_httpx_client,
)
assert response.status_code == status.HTTP_502_BAD_GATEWAY
data = __import__('json').loads(response.body.decode('utf-8'))
assert 'error' in data
async def test_get_hooks_returns_502_when_agent_server_returns_error(self):
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
selected_repository=None,
sandbox_status=SandboxStatus.RUNNING,
)
mock_sandbox = SandboxInfo(
id=sandbox_id,
created_by_user_id='test-user',
status=SandboxStatus.RUNNING,
sandbox_spec_id=str(uuid4()),
session_api_key='test-api-key',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000)
],
)
mock_sandbox_spec = SandboxSpecInfo(
id=str(uuid4()), command=None, working_dir='/workspace'
)
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_httpx_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = Mock()
mock_response.status_code = 500
def _raise_http_status_error(*args, **_kwargs):
request = httpx.Request('POST', args[0])
response = httpx.Response(status_code=500, text='Internal Server Error')
raise httpx.HTTPStatusError(
'Server error', request=request, response=response
)
mock_httpx_client.post = AsyncMock(side_effect=_raise_http_status_error)
response = await get_conversation_hooks(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=mock_sandbox_spec_service,
httpx_client=mock_httpx_client,
)
assert response.status_code == status.HTTP_502_BAD_GATEWAY
data = __import__('json').loads(response.body.decode('utf-8'))
assert 'error' in data
async def test_get_hooks_returns_404_when_conversation_not_found(self):
conversation_id = uuid4()
mock_app_conversation_service = MagicMock()
mock_app_conversation_service.get_app_conversation = AsyncMock(
return_value=None
)
response = await get_conversation_hooks(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=MagicMock(),
sandbox_spec_service=MagicMock(),
httpx_client=AsyncMock(spec=httpx.AsyncClient),
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_get_hooks_returns_404_when_sandbox_not_running(self):
conversation_id = uuid4()
sandbox_id = str(uuid4())
mock_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test-user',
sandbox_id=sandbox_id,
sandbox_status=SandboxStatus.RUNNING,
)
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)
response = await get_conversation_hooks(
conversation_id=conversation_id,
app_conversation_service=mock_app_conversation_service,
sandbox_service=mock_sandbox_service,
sandbox_spec_service=MagicMock(),
httpx_client=AsyncMock(spec=httpx.AsyncClient),
)
assert response.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -123,6 +123,10 @@ class TestLiveStatusAppConversationService:
self.mock_sandbox.id = uuid4()
self.mock_sandbox.status = SandboxStatus.RUNNING
# Default mock for hooks loading - returns None (no hooks found)
# Tests that specifically test hooks loading can override this mock
self.service._load_hooks_from_workspace = AsyncMock(return_value=None)
def test_apply_suggested_task_sets_prompt_and_trigger(self):
"""Test suggested task prompts populate initial message and trigger."""
suggested_task = SuggestedTask(
@@ -179,6 +183,7 @@ class TestLiveStatusAppConversationService:
with pytest.raises(ValueError, match='empty prompt'):
self.service._apply_suggested_task(request)
@pytest.mark.asyncio
async def test_setup_secrets_for_git_providers_no_provider_tokens(self):
"""Test _setup_secrets_for_git_providers with no provider tokens."""
# Arrange
@@ -1139,6 +1144,8 @@ class TestLiveStatusAppConversationService:
side_effect=Exception('Skills loading failed')
)
# Note: hooks loading is already mocked in setup_method() to return None
# Act
with patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service._logger'
@@ -3144,3 +3151,275 @@ class TestAppConversationStartRequestWithPlugins:
assert request.plugins[0].source == 'github:owner/plugin1'
assert request.plugins[1].repo_path == 'plugins/sub'
assert request.plugins[2].source == '/local/path'
class TestLoadHooksFromWorkspace:
"""Test cases for _load_hooks_from_workspace method."""
def setup_method(self):
"""Set up test fixtures."""
# Create mock dependencies
self.mock_user_context = Mock(spec=UserContext)
self.mock_jwt_service = Mock()
self.mock_sandbox_service = Mock()
self.mock_sandbox_spec_service = Mock()
self.mock_app_conversation_info_service = Mock()
self.mock_app_conversation_start_task_service = Mock()
self.mock_event_callback_service = Mock()
self.mock_event_service = Mock()
self.mock_httpx_client = AsyncMock()
# Create service instance
self.service = LiveStatusAppConversationService(
init_git_in_empty_workspace=True,
user_context=self.mock_user_context,
app_conversation_info_service=self.mock_app_conversation_info_service,
app_conversation_start_task_service=self.mock_app_conversation_start_task_service,
event_callback_service=self.mock_event_callback_service,
event_service=self.mock_event_service,
sandbox_service=self.mock_sandbox_service,
sandbox_spec_service=self.mock_sandbox_spec_service,
jwt_service=self.mock_jwt_service,
sandbox_startup_timeout=30,
sandbox_startup_poll_frequency=1,
httpx_client=self.mock_httpx_client,
web_url='https://test.example.com',
openhands_provider_base_url='https://provider.example.com',
access_token_hard_timeout=None,
app_mode='test',
)
@pytest.mark.asyncio
async def test_load_hooks_from_workspace_success(self):
"""Test loading hooks from workspace when hooks.json exists."""
# Arrange
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
mock_remote_workspace.host = 'http://agent-server:8000'
mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'}
hooks_response = {
'hook_config': {
'stop': [
{
'matcher': '*',
'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}],
}
]
}
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = hooks_response
mock_response.raise_for_status = Mock()
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
# Act
result = await self.service._load_hooks_from_workspace(
mock_remote_workspace, '/workspace'
)
# Assert
assert result is not None
assert not result.is_empty()
self.mock_httpx_client.post.assert_called_once_with(
'http://agent-server:8000/api/hooks',
json={'project_dir': '/workspace'},
headers={
'Content-Type': 'application/json',
'X-Session-API-Key': 'test-key',
},
timeout=30.0,
)
@pytest.mark.asyncio
async def test_load_hooks_from_workspace_file_not_found(self):
"""Test loading hooks when hooks.json does not exist."""
# Arrange
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
mock_remote_workspace.host = 'http://agent-server:8000'
mock_remote_workspace._headers = {}
# Agent server returns hook_config: None when file not found
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'hook_config': None}
mock_response.raise_for_status = Mock()
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
# Act
result = await self.service._load_hooks_from_workspace(
mock_remote_workspace, '/workspace'
)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_load_hooks_from_workspace_empty_hooks(self):
"""Test loading hooks when hooks.json is empty or has no hooks."""
# Arrange
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
mock_remote_workspace.host = 'http://agent-server:8000'
mock_remote_workspace._headers = {}
# Agent server returns empty hook_config
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {'hook_config': {}}
mock_response.raise_for_status = Mock()
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
# Act
result = await self.service._load_hooks_from_workspace(
mock_remote_workspace, '/workspace'
)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_load_hooks_from_workspace_http_error(self):
"""Test loading hooks when HTTP request fails."""
# Arrange
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
mock_remote_workspace.host = 'http://agent-server:8000'
mock_remote_workspace._headers = {}
self.mock_httpx_client.post = AsyncMock(
side_effect=Exception('Connection error')
)
# Act
result = await self.service._load_hooks_from_workspace(
mock_remote_workspace, '/workspace'
)
# Assert
assert result is None
def test_get_project_dir_for_hooks_with_selected_repository(self):
"""Test get_project_dir_for_hooks with a selected repository."""
from openhands.app_server.app_conversation.hook_loader import (
get_project_dir_for_hooks,
)
result = get_project_dir_for_hooks(
'/workspace/project',
'OpenHands/software-agent-sdk',
)
assert result == '/workspace/project/software-agent-sdk'
def test_get_project_dir_for_hooks_without_selected_repository(self):
"""Test get_project_dir_for_hooks without a selected repository."""
from openhands.app_server.app_conversation.hook_loader import (
get_project_dir_for_hooks,
)
result = get_project_dir_for_hooks('/workspace/project', None)
assert result == '/workspace/project'
def test_get_project_dir_for_hooks_with_empty_string(self):
"""Test get_project_dir_for_hooks with empty string repository."""
from openhands.app_server.app_conversation.hook_loader import (
get_project_dir_for_hooks,
)
# Empty string should be treated as no repository
result = get_project_dir_for_hooks('/workspace/project', '')
assert result == '/workspace/project'
@pytest.mark.asyncio
async def test_load_hooks_from_workspace_with_project_dir(self):
"""Test loading hooks with a pre-resolved project_dir.
The caller is responsible for computing the project_dir (which
already includes the repo name when a repo is selected).
_load_hooks_from_workspace should use the project_dir as-is.
"""
# Arrange
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
mock_remote_workspace.host = 'http://agent-server:8000'
mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'}
hooks_response = {
'hook_config': {
'stop': [
{
'matcher': '*',
'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}],
}
]
}
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = hooks_response
mock_response.raise_for_status = Mock()
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
# Act - project_dir already includes repo name
result = await self.service._load_hooks_from_workspace(
mock_remote_workspace,
'/workspace/project/software-agent-sdk',
)
# Assert
assert result is not None
assert not result.is_empty()
# The project_dir should be passed as-is without doubling
self.mock_httpx_client.post.assert_called_once_with(
'http://agent-server:8000/api/hooks',
json={'project_dir': '/workspace/project/software-agent-sdk'},
headers={
'Content-Type': 'application/json',
'X-Session-API-Key': 'test-key',
},
timeout=30.0,
)
@pytest.mark.asyncio
async def test_load_hooks_from_workspace_base_dir(self):
"""Test loading hooks with a base workspace directory (no repo selected)."""
# Arrange
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
mock_remote_workspace.host = 'http://agent-server:8000'
mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'}
hooks_response = {
'hook_config': {
'stop': [
{
'matcher': '*',
'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}],
}
]
}
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = hooks_response
mock_response.raise_for_status = Mock()
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
# Act - no repo selected, project_dir is base working_dir
result = await self.service._load_hooks_from_workspace(
mock_remote_workspace,
'/workspace/project',
)
# Assert
assert result is not None
self.mock_httpx_client.post.assert_called_once_with(
'http://agent-server:8000/api/hooks',
json={'project_dir': '/workspace/project'},
headers={
'Content-Type': 'application/json',
'X-Session-API-Key': 'test-key',
},
timeout=30.0,
)