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

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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'