mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
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:
293
tests/unit/app_server/test_app_conversation_hooks_endpoint.py
Normal file
293
tests/unit/app_server/test_app_conversation_hooks_endpoint.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user