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