mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(app_server): start conversations with remote plugins via REST API (#12338)
- Add `PluginSpec` model with plugin configuration parameters extending SDK's `PluginSource` - Extend app-conversations API to accept plugins specification in `AppConversationStartRequest` - Propagate plugin source, ref, and repo_path to agent server's `StartConversationRequest` - Include plugin parameters in initial conversation message for agent context Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
committed by
GitHub
parent
11c87caba4
commit
b6ce45b474
@@ -1786,3 +1786,855 @@ class TestLiveStatusAppConversationService:
|
||||
stdio_server = mcp_servers['stdio-server']
|
||||
assert stdio_server['command'] == 'npx'
|
||||
assert stdio_server['env'] == {'TOKEN': 'value'}
|
||||
|
||||
|
||||
class TestPluginHandling:
|
||||
"""Test cases for plugin-related functionality in LiveStatusAppConversationService."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock dependencies
|
||||
self.mock_user_context = Mock(spec=UserContext)
|
||||
self.mock_user_auth = Mock()
|
||||
self.mock_user_context.user_auth = self.mock_user_auth
|
||||
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 = Mock()
|
||||
|
||||
# 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',
|
||||
)
|
||||
|
||||
# Mock user info
|
||||
self.mock_user = Mock()
|
||||
self.mock_user.id = 'test_user_123'
|
||||
self.mock_user.llm_model = 'gpt-4'
|
||||
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
|
||||
self.mock_user.llm_api_key = 'test_api_key'
|
||||
self.mock_user.confirmation_mode = False
|
||||
self.mock_user.search_api_key = None
|
||||
self.mock_user.condenser_max_size = None
|
||||
self.mock_user.mcp_config = None
|
||||
self.mock_user.security_analyzer = None
|
||||
|
||||
# Mock sandbox
|
||||
self.mock_sandbox = Mock(spec=SandboxInfo)
|
||||
self.mock_sandbox.id = uuid4()
|
||||
self.mock_sandbox.status = SandboxStatus.RUNNING
|
||||
|
||||
def test_construct_initial_message_with_plugin_params_no_plugins(self):
|
||||
"""Test _construct_initial_message_with_plugin_params with no plugins returns original message."""
|
||||
from openhands.agent_server.models import SendMessageRequest, TextContent
|
||||
|
||||
# Test with None initial message and None plugins
|
||||
result = self.service._construct_initial_message_with_plugin_params(None, None)
|
||||
assert result is None
|
||||
|
||||
# Test with None initial message and empty plugins list
|
||||
result = self.service._construct_initial_message_with_plugin_params(None, [])
|
||||
assert result is None
|
||||
|
||||
# Test with initial message but None plugins
|
||||
initial_msg = SendMessageRequest(content=[TextContent(text='Hello world')])
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
initial_msg, None
|
||||
)
|
||||
assert result is initial_msg
|
||||
|
||||
def test_construct_initial_message_with_plugin_params_no_params(self):
|
||||
"""Test _construct_initial_message_with_plugin_params with plugins but no parameters."""
|
||||
from openhands.agent_server.models import SendMessageRequest, TextContent
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Plugin with no parameters
|
||||
plugins = [PluginSpec(source='github:owner/repo')]
|
||||
|
||||
# Test with None initial message
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
None, plugins
|
||||
)
|
||||
assert result is None
|
||||
|
||||
# Test with initial message
|
||||
initial_msg = SendMessageRequest(content=[TextContent(text='Hello world')])
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
initial_msg, plugins
|
||||
)
|
||||
assert result is initial_msg
|
||||
|
||||
def test_construct_initial_message_with_plugin_params_creates_new_message(self):
|
||||
"""Test _construct_initial_message_with_plugin_params creates message when no initial message."""
|
||||
from openhands.agent_server.models import TextContent
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='github:owner/repo',
|
||||
parameters={'api_key': 'test123', 'debug': True},
|
||||
)
|
||||
]
|
||||
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
None, plugins
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert 'Plugin Configuration Parameters:' in result.content[0].text
|
||||
assert '- api_key: test123' in result.content[0].text
|
||||
assert '- debug: True' in result.content[0].text
|
||||
assert result.run is True
|
||||
|
||||
def test_construct_initial_message_with_plugin_params_appends_to_message(self):
|
||||
"""Test _construct_initial_message_with_plugin_params appends to existing message."""
|
||||
from openhands.agent_server.models import SendMessageRequest, TextContent
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
initial_msg = SendMessageRequest(
|
||||
content=[TextContent(text='Please analyze this codebase')],
|
||||
run=False,
|
||||
)
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='github:owner/repo',
|
||||
ref='v1.0.0',
|
||||
parameters={'target_dir': '/src', 'verbose': True},
|
||||
)
|
||||
]
|
||||
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
initial_msg, plugins
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.content) == 1
|
||||
text = result.content[0].text
|
||||
assert text.startswith('Please analyze this codebase')
|
||||
assert 'Plugin Configuration Parameters:' in text
|
||||
assert '- target_dir: /src' in text
|
||||
assert '- verbose: True' in text
|
||||
assert result.run is False
|
||||
|
||||
def test_construct_initial_message_with_plugin_params_preserves_role(self):
|
||||
"""Test _construct_initial_message_with_plugin_params preserves message role."""
|
||||
from openhands.agent_server.models import SendMessageRequest, TextContent
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
initial_msg = SendMessageRequest(
|
||||
role='system',
|
||||
content=[TextContent(text='System message')],
|
||||
)
|
||||
plugins = [PluginSpec(source='github:owner/repo', parameters={'key': 'value'})]
|
||||
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
initial_msg, plugins
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.role == 'system'
|
||||
|
||||
def test_construct_initial_message_with_plugin_params_empty_content(self):
|
||||
"""Test _construct_initial_message_with_plugin_params handles empty content list."""
|
||||
from openhands.agent_server.models import SendMessageRequest, TextContent
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
initial_msg = SendMessageRequest(content=[])
|
||||
plugins = [PluginSpec(source='github:owner/repo', parameters={'key': 'value'})]
|
||||
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
initial_msg, plugins
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert 'Plugin Configuration Parameters:' in result.content[0].text
|
||||
|
||||
def test_construct_initial_message_with_multiple_plugins(self):
|
||||
"""Test _construct_initial_message_with_plugin_params handles multiple plugins."""
|
||||
from openhands.agent_server.models import TextContent
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='github:owner/plugin1',
|
||||
parameters={'key1': 'value1'},
|
||||
),
|
||||
PluginSpec(
|
||||
source='github:owner/plugin2',
|
||||
parameters={'key2': 'value2'},
|
||||
),
|
||||
]
|
||||
|
||||
result = self.service._construct_initial_message_with_plugin_params(
|
||||
None, plugins
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
text = result.content[0].text
|
||||
assert 'Plugin Configuration Parameters:' in text
|
||||
# Multiple plugins should show grouped by plugin name
|
||||
assert 'plugin1' in text
|
||||
assert 'plugin2' in text
|
||||
assert 'key1: value1' in text
|
||||
assert 'key2: value2' in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
|
||||
)
|
||||
async def test_finalize_conversation_request_with_plugins(
|
||||
self, mock_experiment_manager
|
||||
):
|
||||
"""Test _finalize_conversation_request passes plugins list to StartConversationRequest."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
mock_agent = Mock(spec=Agent)
|
||||
mock_llm = Mock(spec=LLM)
|
||||
mock_llm.model = 'gpt-4'
|
||||
mock_llm.usage_id = 'agent'
|
||||
|
||||
mock_updated_agent = Mock(spec=Agent)
|
||||
mock_updated_agent.llm = mock_llm
|
||||
mock_updated_agent.condenser = None
|
||||
mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
|
||||
mock_updated_agent
|
||||
)
|
||||
|
||||
workspace = LocalWorkspace(working_dir='/test')
|
||||
secrets = {'test': StaticSecret(value='secret')}
|
||||
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='github:owner/my-plugin',
|
||||
ref='v1.0.0',
|
||||
parameters={'api_key': 'test123'},
|
||||
)
|
||||
]
|
||||
|
||||
# Act
|
||||
result = await self.service._finalize_conversation_request(
|
||||
mock_agent,
|
||||
None,
|
||||
self.mock_user,
|
||||
workspace,
|
||||
None,
|
||||
secrets,
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
'/test/dir',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, StartConversationRequest)
|
||||
assert result.plugins is not None
|
||||
assert len(result.plugins) == 1
|
||||
assert result.plugins[0].source == 'github:owner/my-plugin'
|
||||
assert result.plugins[0].ref == 'v1.0.0'
|
||||
# Also verify initial message contains plugin params
|
||||
assert result.initial_message is not None
|
||||
assert (
|
||||
'Plugin Configuration Parameters:' in result.initial_message.content[0].text
|
||||
)
|
||||
assert '- api_key: test123' in result.initial_message.content[0].text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
|
||||
)
|
||||
async def test_finalize_conversation_request_without_plugins(
|
||||
self, mock_experiment_manager
|
||||
):
|
||||
"""Test _finalize_conversation_request without plugins sets plugins to None."""
|
||||
# Arrange
|
||||
mock_agent = Mock(spec=Agent)
|
||||
mock_llm = Mock(spec=LLM)
|
||||
mock_llm.model = 'gpt-4'
|
||||
mock_llm.usage_id = 'agent'
|
||||
|
||||
mock_updated_agent = Mock(spec=Agent)
|
||||
mock_updated_agent.llm = mock_llm
|
||||
mock_updated_agent.condenser = None
|
||||
mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
|
||||
mock_updated_agent
|
||||
)
|
||||
|
||||
workspace = LocalWorkspace(working_dir='/test')
|
||||
secrets = {}
|
||||
|
||||
# Act
|
||||
result = await self.service._finalize_conversation_request(
|
||||
mock_agent,
|
||||
None,
|
||||
self.mock_user,
|
||||
workspace,
|
||||
None,
|
||||
secrets,
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
'/test/dir',
|
||||
plugins=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, StartConversationRequest)
|
||||
assert result.plugins is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
|
||||
)
|
||||
async def test_finalize_conversation_request_plugin_without_ref(
|
||||
self, mock_experiment_manager
|
||||
):
|
||||
"""Test _finalize_conversation_request with plugin that has no ref."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
mock_agent = Mock(spec=Agent)
|
||||
mock_llm = Mock(spec=LLM)
|
||||
mock_llm.model = 'gpt-4'
|
||||
mock_llm.usage_id = 'agent'
|
||||
|
||||
mock_updated_agent = Mock(spec=Agent)
|
||||
mock_updated_agent.llm = mock_llm
|
||||
mock_updated_agent.condenser = None
|
||||
mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
|
||||
mock_updated_agent
|
||||
)
|
||||
|
||||
workspace = LocalWorkspace(working_dir='/test')
|
||||
secrets = {}
|
||||
|
||||
# Plugin without ref or parameters
|
||||
plugins = [PluginSpec(source='github:owner/my-plugin')]
|
||||
|
||||
# Act
|
||||
result = await self.service._finalize_conversation_request(
|
||||
mock_agent,
|
||||
None,
|
||||
self.mock_user,
|
||||
workspace,
|
||||
None,
|
||||
secrets,
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
'/test/dir',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, StartConversationRequest)
|
||||
assert result.plugins is not None
|
||||
assert len(result.plugins) == 1
|
||||
assert result.plugins[0].source == 'github:owner/my-plugin'
|
||||
assert result.plugins[0].ref is None
|
||||
# No parameters, so initial message should be None
|
||||
assert result.initial_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
|
||||
)
|
||||
async def test_finalize_conversation_request_plugin_with_repo_path(
|
||||
self, mock_experiment_manager
|
||||
):
|
||||
"""Test _finalize_conversation_request passes repo_path to PluginSource."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
mock_agent = Mock(spec=Agent)
|
||||
mock_llm = Mock(spec=LLM)
|
||||
mock_llm.model = 'gpt-4'
|
||||
mock_llm.usage_id = 'agent'
|
||||
|
||||
mock_updated_agent = Mock(spec=Agent)
|
||||
mock_updated_agent.llm = mock_llm
|
||||
mock_updated_agent.condenser = None
|
||||
mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
|
||||
mock_updated_agent
|
||||
)
|
||||
|
||||
workspace = LocalWorkspace(working_dir='/test')
|
||||
secrets = {}
|
||||
|
||||
# Plugin with repo_path (for marketplace repos containing multiple plugins)
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='github:owner/marketplace-repo',
|
||||
ref='main',
|
||||
repo_path='plugins/city-weather',
|
||||
)
|
||||
]
|
||||
|
||||
# Act
|
||||
result = await self.service._finalize_conversation_request(
|
||||
mock_agent,
|
||||
None,
|
||||
self.mock_user,
|
||||
workspace,
|
||||
None,
|
||||
secrets,
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
'/test/dir',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, StartConversationRequest)
|
||||
assert result.plugins is not None
|
||||
assert len(result.plugins) == 1
|
||||
assert result.plugins[0].source == 'github:owner/marketplace-repo'
|
||||
assert result.plugins[0].ref == 'main'
|
||||
assert result.plugins[0].repo_path == 'plugins/city-weather'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
|
||||
)
|
||||
async def test_finalize_conversation_request_multiple_plugins(
|
||||
self, mock_experiment_manager
|
||||
):
|
||||
"""Test _finalize_conversation_request with multiple plugins."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
mock_agent = Mock(spec=Agent)
|
||||
mock_llm = Mock(spec=LLM)
|
||||
mock_llm.model = 'gpt-4'
|
||||
mock_llm.usage_id = 'agent'
|
||||
|
||||
mock_updated_agent = Mock(spec=Agent)
|
||||
mock_updated_agent.llm = mock_llm
|
||||
mock_updated_agent.condenser = None
|
||||
mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
|
||||
mock_updated_agent
|
||||
)
|
||||
|
||||
workspace = LocalWorkspace(working_dir='/test')
|
||||
secrets = {}
|
||||
|
||||
# Multiple plugins
|
||||
plugins = [
|
||||
PluginSpec(source='github:owner/security-plugin', ref='v2.0.0'),
|
||||
PluginSpec(
|
||||
source='github:owner/monorepo',
|
||||
repo_path='plugins/logging',
|
||||
),
|
||||
PluginSpec(source='/local/path/to/plugin'),
|
||||
]
|
||||
|
||||
# Act
|
||||
result = await self.service._finalize_conversation_request(
|
||||
mock_agent,
|
||||
None,
|
||||
self.mock_user,
|
||||
workspace,
|
||||
None,
|
||||
secrets,
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
'/test/dir',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, StartConversationRequest)
|
||||
assert result.plugins is not None
|
||||
assert len(result.plugins) == 3
|
||||
assert result.plugins[0].source == 'github:owner/security-plugin'
|
||||
assert result.plugins[0].ref == 'v2.0.0'
|
||||
assert result.plugins[1].source == 'github:owner/monorepo'
|
||||
assert result.plugins[1].repo_path == 'plugins/logging'
|
||||
assert result.plugins[2].source == '/local/path/to/plugin'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_start_conversation_request_for_user_with_plugins(self):
|
||||
"""Test _build_start_conversation_request_for_user passes plugins to finalize method."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
self.mock_user_context.get_user_info.return_value = self.mock_user
|
||||
self.mock_user_context.get_secrets.return_value = {}
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None)
|
||||
self.mock_user_context.get_mcp_api_key.return_value = None
|
||||
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='https://github.com/org/plugin.git',
|
||||
ref='main',
|
||||
parameters={'config_file': 'custom.yaml'},
|
||||
)
|
||||
]
|
||||
|
||||
# Mock _finalize_conversation_request to capture the call
|
||||
mock_finalize = AsyncMock(return_value=Mock(spec=StartConversationRequest))
|
||||
self.service._finalize_conversation_request = mock_finalize
|
||||
|
||||
# Act
|
||||
await self.service._build_start_conversation_request_for_user(
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
'/workspace',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_finalize.assert_called_once()
|
||||
call_kwargs = mock_finalize.call_args.kwargs
|
||||
assert call_kwargs['plugins'] == plugins
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_start_conversation_request_for_user_without_plugins(self):
|
||||
"""Test _build_start_conversation_request_for_user works without plugins."""
|
||||
# Arrange
|
||||
self.mock_user_context.get_user_info.return_value = self.mock_user
|
||||
self.mock_user_context.get_secrets.return_value = {}
|
||||
self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None)
|
||||
self.mock_user_context.get_mcp_api_key.return_value = None
|
||||
|
||||
# Mock _finalize_conversation_request
|
||||
mock_finalize = AsyncMock(return_value=Mock(spec=StartConversationRequest))
|
||||
self.service._finalize_conversation_request = mock_finalize
|
||||
|
||||
# Act
|
||||
await self.service._build_start_conversation_request_for_user(
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
'/workspace',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_finalize.assert_called_once()
|
||||
call_kwargs = mock_finalize.call_args.kwargs
|
||||
assert call_kwargs.get('plugins') is None
|
||||
|
||||
|
||||
class TestPluginSpecModel:
|
||||
"""Test cases for the PluginSpec model."""
|
||||
|
||||
def test_plugin_spec_with_all_fields(self):
|
||||
"""Test PluginSpec with all fields provided."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(
|
||||
source='github:owner/repo',
|
||||
ref='v1.0.0',
|
||||
repo_path='plugins/my-plugin',
|
||||
parameters={'key1': 'value1', 'key2': 123, 'key3': True},
|
||||
)
|
||||
|
||||
assert plugin.source == 'github:owner/repo'
|
||||
assert plugin.ref == 'v1.0.0'
|
||||
assert plugin.repo_path == 'plugins/my-plugin'
|
||||
assert plugin.parameters == {'key1': 'value1', 'key2': 123, 'key3': True}
|
||||
|
||||
def test_plugin_spec_with_only_source(self):
|
||||
"""Test PluginSpec with only source provided."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(source='https://github.com/owner/repo.git')
|
||||
|
||||
assert plugin.source == 'https://github.com/owner/repo.git'
|
||||
assert plugin.ref is None
|
||||
assert plugin.repo_path is None
|
||||
assert plugin.parameters is None
|
||||
|
||||
def test_plugin_spec_serialization(self):
|
||||
"""Test PluginSpec serialization to JSON."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(
|
||||
source='github:owner/repo',
|
||||
ref='main',
|
||||
repo_path='plugins/my-plugin',
|
||||
parameters={'debug': True},
|
||||
)
|
||||
|
||||
json_data = plugin.model_dump()
|
||||
assert json_data == {
|
||||
'source': 'github:owner/repo',
|
||||
'ref': 'main',
|
||||
'repo_path': 'plugins/my-plugin',
|
||||
'parameters': {'debug': True},
|
||||
}
|
||||
|
||||
def test_plugin_spec_deserialization(self):
|
||||
"""Test PluginSpec deserialization from dict."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
data = {
|
||||
'source': 'github:owner/repo',
|
||||
'ref': 'v2.0.0',
|
||||
'repo_path': 'plugins/weather',
|
||||
'parameters': {'timeout': 30},
|
||||
}
|
||||
|
||||
plugin = PluginSpec.model_validate(data)
|
||||
|
||||
assert plugin.source == 'github:owner/repo'
|
||||
assert plugin.ref == 'v2.0.0'
|
||||
assert plugin.repo_path == 'plugins/weather'
|
||||
assert plugin.parameters == {'timeout': 30}
|
||||
|
||||
def test_plugin_spec_display_name_github_format(self):
|
||||
"""Test display_name extracts repo name from github:owner/repo format."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(source='github:owner/my-plugin')
|
||||
assert plugin.display_name == 'my-plugin'
|
||||
|
||||
def test_plugin_spec_display_name_git_url(self):
|
||||
"""Test display_name extracts repo name from git URL."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(source='https://github.com/owner/repo.git')
|
||||
assert plugin.display_name == 'repo.git'
|
||||
|
||||
def test_plugin_spec_display_name_local_path(self):
|
||||
"""Test display_name extracts directory name from local path."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(source='/local/path/to/plugin')
|
||||
assert plugin.display_name == 'plugin'
|
||||
|
||||
def test_plugin_spec_display_name_no_slash(self):
|
||||
"""Test display_name returns source as-is when no slash present."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(source='local-plugin')
|
||||
assert plugin.display_name == 'local-plugin'
|
||||
|
||||
def test_plugin_spec_format_params_as_text(self):
|
||||
"""Test format_params_as_text formats parameters as text."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(
|
||||
source='github:owner/repo',
|
||||
parameters={'key1': 'value1', 'key2': 123},
|
||||
)
|
||||
|
||||
result = plugin.format_params_as_text()
|
||||
assert result == '- key1: value1\n- key2: 123'
|
||||
|
||||
def test_plugin_spec_format_params_as_text_with_indent(self):
|
||||
"""Test format_params_as_text with custom indent."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(
|
||||
source='github:owner/repo',
|
||||
parameters={'debug': True},
|
||||
)
|
||||
|
||||
result = plugin.format_params_as_text(indent=' ')
|
||||
assert result == ' - debug: True'
|
||||
|
||||
def test_plugin_spec_format_params_as_text_no_params(self):
|
||||
"""Test format_params_as_text returns None when no parameters."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugin = PluginSpec(source='github:owner/repo')
|
||||
assert plugin.format_params_as_text() is None
|
||||
|
||||
def test_plugin_spec_inherits_repo_path_validation(self):
|
||||
"""Test PluginSpec inherits validation from SDK's PluginSource."""
|
||||
import pytest
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
# Should reject absolute paths
|
||||
with pytest.raises(ValueError, match='must be relative'):
|
||||
PluginSpec(source='github:owner/repo', repo_path='/absolute/path')
|
||||
|
||||
# Should reject parent traversal
|
||||
with pytest.raises(ValueError, match="cannot contain '..'"):
|
||||
PluginSpec(source='github:owner/repo', repo_path='../parent/path')
|
||||
|
||||
|
||||
class TestAppConversationStartRequestWithPlugins:
|
||||
"""Test cases for AppConversationStartRequest with plugins field."""
|
||||
|
||||
def test_start_request_with_plugins(self):
|
||||
"""Test AppConversationStartRequest with plugins field."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugins = [
|
||||
PluginSpec(
|
||||
source='github:owner/my-plugin',
|
||||
ref='v1.0.0',
|
||||
parameters={'api_key': 'test'},
|
||||
)
|
||||
]
|
||||
|
||||
request = AppConversationStartRequest(
|
||||
title='Test conversation',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
assert request.plugins is not None
|
||||
assert len(request.plugins) == 1
|
||||
assert request.plugins[0].source == 'github:owner/my-plugin'
|
||||
assert request.plugins[0].ref == 'v1.0.0'
|
||||
assert request.plugins[0].parameters == {'api_key': 'test'}
|
||||
|
||||
def test_start_request_without_plugins(self):
|
||||
"""Test AppConversationStartRequest without plugins field (backwards compatible)."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
)
|
||||
|
||||
request = AppConversationStartRequest(
|
||||
title='Test conversation',
|
||||
)
|
||||
|
||||
assert request.plugins is None
|
||||
|
||||
def test_start_request_serialization_with_plugins(self):
|
||||
"""Test AppConversationStartRequest serialization includes plugins."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugins = [PluginSpec(source='github:owner/repo')]
|
||||
request = AppConversationStartRequest(plugins=plugins)
|
||||
|
||||
json_data = request.model_dump()
|
||||
|
||||
assert 'plugins' in json_data
|
||||
assert len(json_data['plugins']) == 1
|
||||
assert json_data['plugins'][0]['source'] == 'github:owner/repo'
|
||||
|
||||
def test_start_request_deserialization_with_plugins(self):
|
||||
"""Test AppConversationStartRequest deserialization from JSON with plugins."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
)
|
||||
|
||||
data = {
|
||||
'title': 'Test',
|
||||
'plugins': [
|
||||
{
|
||||
'source': 'github:owner/plugin',
|
||||
'ref': 'main',
|
||||
'parameters': {'key': 'value'},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
request = AppConversationStartRequest.model_validate(data)
|
||||
|
||||
assert request.plugins is not None
|
||||
assert len(request.plugins) == 1
|
||||
assert request.plugins[0].source == 'github:owner/plugin'
|
||||
assert request.plugins[0].ref == 'main'
|
||||
assert request.plugins[0].parameters == {'key': 'value'}
|
||||
|
||||
def test_start_request_with_multiple_plugins(self):
|
||||
"""Test AppConversationStartRequest with multiple plugins."""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
PluginSpec,
|
||||
)
|
||||
|
||||
plugins = [
|
||||
PluginSpec(source='github:owner/plugin1', ref='v1.0.0'),
|
||||
PluginSpec(source='github:owner/plugin2', repo_path='plugins/sub'),
|
||||
PluginSpec(source='/local/path'),
|
||||
]
|
||||
|
||||
request = AppConversationStartRequest(
|
||||
title='Test conversation',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
assert request.plugins is not None
|
||||
assert len(request.plugins) == 3
|
||||
assert request.plugins[0].source == 'github:owner/plugin1'
|
||||
assert request.plugins[1].repo_path == 'plugins/sub'
|
||||
assert request.plugins[2].source == '/local/path'
|
||||
|
||||
Reference in New Issue
Block a user