diff --git a/pytest.ini b/pytest.ini index ef5b705d..69b389b2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -26,7 +26,7 @@ markers = # Coverage options (when using pytest-cov) [coverage:run] -source = langbot.pkg +source = langbot omit = */tests/* */test_*.py diff --git a/run_tests.sh b/run_tests.sh index 931117ef..8e68d02a 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -22,7 +22,7 @@ echo "Running all unit tests..." # Run tests with coverage pytest tests/unit_tests/ -v --tb=short \ - --cov=pkg \ + --cov=langbot \ --cov-report=xml \ "$@" diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index 0e80d0d4..44cf6f89 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -59,7 +59,10 @@ class PipelinesRouterGroup(group.RouterGroup): if pipeline is None: return self.http_status(404, -1, 'pipeline not found') - plugins = await self.ap.plugin_connector.list_plugins() + # Only include plugins with pipeline-related components (Command, EventListener, Tool) + # Plugins that only have KnowledgeRetriever components are not suitable for pipeline extensions + pipeline_component_kinds = ['Command', 'EventListener', 'Tool'] + plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) extensions_prefs = pipeline.get('extensions_preferences', {}) diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 381549a2..3e206b3e 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -284,12 +284,35 @@ class PluginRuntimeConnector: task_context.trace('Cleaning up plugin configuration and storage...') await self.handler.cleanup_plugin_data(plugin_author, plugin_name) - async def list_plugins(self) -> list[dict[str, Any]]: + async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]: + """List plugins, optionally filtered by component kinds. + + Args: + component_kinds: Optional list of component kinds to filter by. + If provided, only plugins that contain at least one + component of the specified kinds will be returned. + E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins. + """ if not self.is_enable_plugin: return [] plugins = await self.handler.list_plugins() + # Filter plugins by component kinds if specified + if component_kinds is not None: + filtered_plugins = [] + for plugin in plugins: + components = plugin.get('components', []) + has_matching_component = False + for component in components: + component_kind = component.get('manifest', {}).get('manifest', {}).get('kind', '') + if component_kind in component_kinds: + has_matching_component = True + break + if has_matching_component: + filtered_plugins.append(plugin) + plugins = filtered_plugins + # Sort plugins: debug plugins first, then by installation time (newest first) # Get installation timestamps from database in a single query plugin_timestamps = {} diff --git a/tests/unit_tests/plugin/test_plugin_component_filtering.py b/tests/unit_tests/plugin/test_plugin_component_filtering.py new file mode 100644 index 00000000..b83667c5 --- /dev/null +++ b/tests/unit_tests/plugin/test_plugin_component_filtering.py @@ -0,0 +1,363 @@ +"""Test plugin list filtering by component kinds.""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +import pytest + + +@pytest.mark.asyncio +async def test_plugin_list_filter_by_component_kinds(): + """Test that plugins can be filtered by component kinds.""" + from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + + # Mock the application + mock_app = MagicMock() + mock_app.instance_config.data.get.return_value = {'enable': True} + mock_app.logger = MagicMock() + + # Create connector + connector = PluginRuntimeConnector(mock_app, AsyncMock()) + connector.handler = MagicMock() + + # Mock plugin data with different component kinds + mock_plugins = [ + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author1', + 'name': 'plugin_with_tool', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'Tool', + 'metadata': {'name': 'tool1'} + } + } + } + ] + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author2', + 'name': 'plugin_with_knowledge_retriever_only', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'KnowledgeRetriever', + 'metadata': {'name': 'retriever1'} + } + } + } + ] + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author3', + 'name': 'plugin_with_command', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'Command', + 'metadata': {'name': 'cmd1'} + } + } + } + ] + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author4', + 'name': 'plugin_with_event_listener', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'EventListener', + 'metadata': {'name': 'listener1'} + } + } + } + ] + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author5', + 'name': 'plugin_with_mixed_components', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'KnowledgeRetriever', + 'metadata': {'name': 'retriever2'} + } + } + }, + { + 'manifest': { + 'manifest': { + 'kind': 'Tool', + 'metadata': {'name': 'tool2'} + } + } + } + ] + }, + ] + + connector.handler.list_plugins = AsyncMock(return_value=mock_plugins) + + # Mock database query + async def mock_execute_async(query): + mock_result = MagicMock() + mock_result.__iter__ = lambda self: iter([]) + return mock_result + + mock_app.persistence_mgr.execute_async = mock_execute_async + + # Test filtering by pipeline component kinds (Command, EventListener, Tool) + pipeline_component_kinds = ['Command', 'EventListener', 'Tool'] + result = await connector.list_plugins(component_kinds=pipeline_component_kinds) + + # Verify that only plugins with pipeline-related components are returned + assert len(result) == 4 + plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result] + assert 'plugin_with_tool' in plugin_names + assert 'plugin_with_command' in plugin_names + assert 'plugin_with_event_listener' in plugin_names + assert 'plugin_with_mixed_components' in plugin_names + # Plugin with only KnowledgeRetriever should NOT be included + assert 'plugin_with_knowledge_retriever_only' not in plugin_names + + +@pytest.mark.asyncio +async def test_plugin_list_filter_no_filter(): + """Test that all plugins are returned when no filter is specified.""" + from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + + # Mock the application + mock_app = MagicMock() + mock_app.instance_config.data.get.return_value = {'enable': True} + mock_app.logger = MagicMock() + + # Create connector + connector = PluginRuntimeConnector(mock_app, AsyncMock()) + connector.handler = MagicMock() + + # Mock plugin data with different component kinds + mock_plugins = [ + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author1', + 'name': 'plugin1', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'Tool', + 'metadata': {'name': 'tool1'} + } + } + } + ] + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author2', + 'name': 'plugin2', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'KnowledgeRetriever', + 'metadata': {'name': 'retriever1'} + } + } + } + ] + }, + ] + + connector.handler.list_plugins = AsyncMock(return_value=mock_plugins) + + # Mock database query + async def mock_execute_async(query): + mock_result = MagicMock() + mock_result.__iter__ = lambda self: iter([]) + return mock_result + + mock_app.persistence_mgr.execute_async = mock_execute_async + + # Test without filter - should return all plugins + result = await connector.list_plugins() + + assert len(result) == 2 + plugin_names = [p['manifest']['manifest']['metadata']['name'] for p in result] + assert 'plugin1' in plugin_names + assert 'plugin2' in plugin_names + + +@pytest.mark.asyncio +async def test_plugin_list_filter_empty_result(): + """Test that empty list is returned when no plugins match the filter.""" + from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + + # Mock the application + mock_app = MagicMock() + mock_app.instance_config.data.get.return_value = {'enable': True} + mock_app.logger = MagicMock() + + # Create connector + connector = PluginRuntimeConnector(mock_app, AsyncMock()) + connector.handler = MagicMock() + + # Mock plugin data - only KnowledgeRetriever plugins + mock_plugins = [ + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author1', + 'name': 'plugin1', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'KnowledgeRetriever', + 'metadata': {'name': 'retriever1'} + } + } + } + ] + }, + ] + + connector.handler.list_plugins = AsyncMock(return_value=mock_plugins) + + # Mock database query + async def mock_execute_async(query): + mock_result = MagicMock() + mock_result.__iter__ = lambda self: iter([]) + return mock_result + + mock_app.persistence_mgr.execute_async = mock_execute_async + + # Filter by Tool kind - should return empty list + result = await connector.list_plugins(component_kinds=['Tool']) + + assert len(result) == 0 + + +@pytest.mark.asyncio +async def test_plugin_list_filter_plugin_without_components(): + """Test that plugins without components are excluded when filtering.""" + from src.langbot.pkg.plugin.connector import PluginRuntimeConnector + + # Mock the application + mock_app = MagicMock() + mock_app.instance_config.data.get.return_value = {'enable': True} + mock_app.logger = MagicMock() + + # Create connector + connector = PluginRuntimeConnector(mock_app, AsyncMock()) + connector.handler = MagicMock() + + # Mock plugin data - one with components, one without + mock_plugins = [ + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author1', + 'name': 'plugin_with_tool', + } + } + }, + 'components': [ + { + 'manifest': { + 'manifest': { + 'kind': 'Tool', + 'metadata': {'name': 'tool1'} + } + } + } + ] + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author2', + 'name': 'plugin_without_components', + } + } + }, + 'components': [] + }, + ] + + connector.handler.list_plugins = AsyncMock(return_value=mock_plugins) + + # Mock database query + async def mock_execute_async(query): + mock_result = MagicMock() + mock_result.__iter__ = lambda self: iter([]) + return mock_result + + mock_app.persistence_mgr.execute_async = mock_execute_async + + # Filter by Tool kind - should return only plugin with Tool + result = await connector.list_plugins(component_kinds=['Tool']) + + assert len(result) == 1 + assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin_with_tool'