diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index a2dcdd7314..22b261c269 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Literal +from typing import Any, Literal from uuid import UUID, uuid4 from pydantic import BaseModel, Field @@ -14,6 +14,7 @@ from openhands.app_server.sandbox.sandbox_models import SandboxStatus from openhands.integrations.service_types import ProviderType from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.llm import MetricsSnapshot +from openhands.sdk.plugin import PluginSource from openhands.storage.data_models.conversation_metadata import ConversationTrigger @@ -24,6 +25,45 @@ class AgentType(Enum): PLAN = 'plan' +class PluginSpec(PluginSource): + """Specification for loading a plugin into a conversation. + + Extends SDK's PluginSource with user-provided plugin configuration parameters. + Inherits source, ref, and repo_path fields along with their validation. + """ + + parameters: dict[str, Any] | None = Field( + default=None, + description='User-provided values for plugin input parameters', + ) + + @property + def display_name(self) -> str: + """Extract a friendly display name from the plugin source. + + Examples: + - 'github:owner/repo' -> 'repo' + - 'https://github.com/owner/repo.git' -> 'repo.git' + - '/local/path' -> 'path' + """ + return self.source.split('/')[-1] if '/' in self.source else self.source + + def format_params_as_text(self, indent: str = '') -> str | None: + """Format parameters as a readable text block for display. + + Args: + indent: Optional prefix to add before each parameter line. + + Returns: + Formatted parameters string, or None if no parameters. + """ + if not self.parameters: + return None + return '\n'.join( + f'{indent}- {key}: {value}' for key, value in self.parameters.items() + ) + + class AppConversationInfo(BaseModel): """Conversation info which does not contain status.""" @@ -118,6 +158,15 @@ class AppConversationStartRequest(OpenHandsModel): public: bool | None = None + # Plugin parameters - for loading remote plugins into the conversation + plugins: list[PluginSpec] | None = Field( + default=None, + description=( + 'List of plugins to load for this conversation. Plugins are loaded ' + 'and their skills/MCP config are merged into the agent.' + ), + ) + class AppConversationUpdateRequest(BaseModel): public: bool @@ -147,7 +196,8 @@ class AppConversationStartTask(OpenHandsModel): Because starting an app conversation can be slow (And can involve starting a sandbox), we kick off a background task for it. Once the conversation is started, the app_conversation_id - is populated.""" + is populated. + """ id: OpenHandsUUID = Field(default_factory=uuid4) created_by_user_id: str | None diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index eae1c5e36e..b85aff067c 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -621,7 +621,6 @@ async def _stream_app_conversation_start( user_context: UserContext, ) -> AsyncGenerator[str, None]: """Stream a json list, item by item.""" - # Because the original dependencies are closed after the method returns, we need # a new dependency context which will continue intil the stream finishes. state = InjectorState() diff --git a/openhands/app_server/app_conversation/app_conversation_service.py b/openhands/app_server/app_conversation/app_conversation_service.py index bff19b0fa7..97918c3267 100644 --- a/openhands/app_server/app_conversation/app_conversation_service.py +++ b/openhands/app_server/app_conversation/app_conversation_service.py @@ -105,7 +105,8 @@ class AppConversationService(ABC): self, conversation_id: UUID, request: AppConversationUpdateRequest ) -> AppConversation | None: """Update an app conversation and return it. Return None if the conversation - did not exist.""" + did not exist. + """ @abstractmethod async def delete_app_conversation(self, conversation_id: UUID) -> bool: diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py index 1ccff099e6..86cab99fae 100644 --- a/openhands/app_server/app_conversation/app_conversation_service_base.py +++ b/openhands/app_server/app_conversation/app_conversation_service_base.py @@ -51,7 +51,8 @@ PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local' class AppConversationServiceBase(AppConversationService, ABC): """App Conversation service which adds git specific functionality. - Sets up repositories and installs hooks""" + Sets up repositories and installs hooks + """ init_git_in_empty_workspace: bool user_context: UserContext @@ -484,7 +485,6 @@ class AppConversationServiceBase(AppConversationService, ABC): security_analyzer_str: String value from settings httpx_client: HTTP client for making API requests """ - if session_api_key is None: return diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index f91ebc8d94..e4abcf312f 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -32,6 +32,7 @@ from openhands.app_server.app_conversation.app_conversation_models import ( AppConversationStartTask, AppConversationStartTaskStatus, AppConversationUpdateRequest, + PluginSpec, ) from openhands.app_server.app_conversation.app_conversation_service import ( AppConversationService, @@ -79,6 +80,7 @@ from openhands.experiments.experiment_manager import ExperimentManagerImpl from openhands.integrations.provider import ProviderType from openhands.sdk import Agent, AgentContext, LocalWorkspace from openhands.sdk.llm import LLM +from openhands.sdk.plugin import PluginSource from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret from openhands.sdk.utils.paging import page_iterator from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace @@ -254,6 +256,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): request.conversation_id, remote_workspace=remote_workspace, selected_repository=request.selected_repository, + plugins=request.plugins, ) ) @@ -954,6 +957,79 @@ class LiveStatusAppConversationService(AppConversationServiceBase): return agent.model_copy(update=updates) return agent + def _construct_initial_message_with_plugin_params( + self, + initial_message: SendMessageRequest | None, + plugins: list[PluginSpec] | None, + ) -> SendMessageRequest | None: + """Incorporate plugin parameters into the initial message if specified. + + Plugin parameters are formatted and appended to the initial message so the + agent has context about the user-provided configuration values. + + Args: + initial_message: The original initial message, if any + plugins: List of plugin specifications with optional parameters + + Returns: + The initial message with plugin parameters incorporated, or the + original message if no plugin parameters are specified + """ + from openhands.agent_server.models import TextContent + + if not plugins: + return initial_message + + # Collect formatted parameters from plugins that have them + plugins_with_params = [p for p in plugins if p.parameters] + if not plugins_with_params: + return initial_message + + # Format parameters, grouped by plugin if multiple + if len(plugins_with_params) == 1: + params_text = plugins_with_params[0].format_params_as_text() + plugin_params_message = ( + f'\n\nPlugin Configuration Parameters:\n{params_text}' + ) + else: + # Group by plugin name for clarity + formatted_plugins = [] + for plugin in plugins_with_params: + params_text = plugin.format_params_as_text(indent=' ') + if params_text: + formatted_plugins.append(f'{plugin.display_name}:\n{params_text}') + + plugin_params_message = ( + '\n\nPlugin Configuration Parameters:\n' + '\n'.join(formatted_plugins) + ) + + if initial_message is None: + # Create a new message with just the plugin parameters + return SendMessageRequest( + content=[TextContent(text=plugin_params_message.strip())], + run=True, + ) + + # Append plugin parameters to existing message content + new_content = list(initial_message.content) + if new_content and isinstance(new_content[-1], TextContent): + # Append to the last text content + last_content = new_content[-1] + new_content[-1] = TextContent( + text=last_content.text + plugin_params_message, + cache_prompt=last_content.cache_prompt, + enable_truncation=last_content.enable_truncation, + ) + else: + # Add as new text content + new_content.append(TextContent(text=plugin_params_message.strip())) + + return SendMessageRequest( + role=initial_message.role, + content=new_content, + run=initial_message.run, + ) + async def _finalize_conversation_request( self, agent: Agent, @@ -966,6 +1042,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): remote_workspace: AsyncRemoteWorkspace | None, selected_repository: str | None, working_dir: str, + plugins: list[PluginSpec] | None = None, ) -> StartConversationRequest: """Finalize the conversation request with experiment variants and skills. @@ -980,6 +1057,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): remote_workspace: Optional remote workspace for skills loading selected_repository: Optional repository name working_dir: Working directory path + plugins: Optional list of plugin specifications to load Returns: Complete StartConversationRequest ready for use @@ -1006,6 +1084,23 @@ class LiveStatusAppConversationService(AppConversationServiceBase): _logger.warning(f'Failed to load skills: {e}', exc_info=True) # Continue without skills - don't fail conversation startup + # Incorporate plugin parameters into initial message if specified + final_initial_message = self._construct_initial_message_with_plugin_params( + initial_message, plugins + ) + + # Convert PluginSpec list to SDK PluginSource list for agent server + sdk_plugins: list[PluginSource] | None = None + if plugins: + sdk_plugins = [ + PluginSource( + source=p.source, + ref=p.ref, + repo_path=p.repo_path, + ) + for p in plugins + ] + # Create and return the final request return StartConversationRequest( conversation_id=conversation_id, @@ -1014,8 +1109,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase): confirmation_policy=self._select_confirmation_policy( bool(user.confirmation_mode), user.security_analyzer ), - initial_message=initial_message, + initial_message=final_initial_message, secrets=secrets, + plugins=sdk_plugins, ) async def _build_start_conversation_request_for_user( @@ -1030,6 +1126,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): conversation_id: UUID | None = None, remote_workspace: AsyncRemoteWorkspace | None = None, selected_repository: str | None = None, + plugins: list[PluginSpec] | None = None, ) -> StartConversationRequest: """Build a complete conversation request for a user. @@ -1038,6 +1135,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): 2. Configuring LLM and MCP settings 3. Creating an agent with appropriate context 4. Finalizing the request with skills and experiment variants + 5. Passing plugins to the agent server for remote plugin loading """ user = await self.user_context.get_user_info() workspace = LocalWorkspace(working_dir=working_dir) @@ -1070,6 +1168,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): remote_workspace, selected_repository, working_dir, + plugins=plugins, ) async def update_agent_server_conversation_title( @@ -1124,7 +1223,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase): self, conversation_id: UUID, request: AppConversationUpdateRequest ) -> AppConversation | None: """Update an app conversation and return it. Return None if the conversation - did not exist.""" + did not exist. + """ info = await self.app_conversation_info_service.get_app_conversation_info( conversation_id ) diff --git a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py index b1891d926e..af4528c9a4 100644 --- a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py @@ -541,7 +541,8 @@ class SQLAppConversationInfoService(AppConversationInfoService): def _fix_timezone(self, value: datetime) -> datetime: """Sqlite does not stpre timezones - and since we can't update the existing models - we assume UTC if the timezone is missing.""" + we assume UTC if the timezone is missing. + """ if not value.tzinfo: value = value.replace(tzinfo=UTC) return value diff --git a/openhands/app_server/app_conversation/sql_app_conversation_start_task_service.py b/openhands/app_server/app_conversation/sql_app_conversation_start_task_service.py index b6c669149c..2ae150e9bf 100644 --- a/openhands/app_server/app_conversation/sql_app_conversation_start_task_service.py +++ b/openhands/app_server/app_conversation/sql_app_conversation_start_task_service.py @@ -68,7 +68,8 @@ class StoredAppConversationStartTask(Base): # type: ignore class SQLAppConversationStartTaskService(AppConversationStartTaskService): """SQL implementation of AppConversationStartTaskService focused on db operations. - This allows storing and retrieving conversation start tasks from the database.""" + This allows storing and retrieving conversation start tasks from the database. + """ session: AsyncSession user_id: str | None = None diff --git a/pyproject.toml b/pyproject.toml index 6b5d738d19..f7aae92be9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -280,9 +280,6 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true } pybase62 = "^1.0.0" # V1 dependencies -#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } -#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } -#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } openhands-sdk = "1.10" openhands-agent-server = "1.10" openhands-tools = "1.10" diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index 3535425bdf..9bcd383a60 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -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'