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:
John-Mason P. Shackelford
2026-01-27 16:26:38 -05:00
committed by GitHub
parent 11c87caba4
commit b6ce45b474
9 changed files with 1014 additions and 13 deletions

View File

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