From ace6d62d76950687654d890ab92276a7ed415784 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 13:46:45 +0800 Subject: [PATCH] perf: Sort installed plugins: debug plugins first, then by installation time (#1798) * Initial plan * Implement plugin list sorting: debug plugins first, then by installation time Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Apply ruff formatting * Add unit tests for plugin list sorting functionality Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Optimize database query to avoid N+1 problem and update tests Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Remove redundant assertion in test Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: plugin list sorting --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin --- src/langbot/pkg/plugin/connector.py | 58 ++++- tests/unit_tests/plugin/__init__.py | 1 + .../plugin/test_plugin_list_sorting.py | 228 ++++++++++++++++++ web/src/app/home/plugins/plugins.module.css | 1 + 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/plugin/__init__.py create mode 100644 tests/unit_tests/plugin/test_plugin_list_sorting.py diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index d0162a7f..d63a8233 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -7,6 +7,7 @@ import typing import os import sys import httpx +import sqlalchemy from async_lru import alru_cache from langbot_plugin.api.entities.builtin.pipeline.query import provider_session @@ -27,6 +28,7 @@ from langbot_plugin.api.entities.builtin.command import ( ) from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from ..core import taskmgr +from ..entity.persistence import plugin as persistence_plugin class PluginRuntimeConnector: @@ -279,7 +281,61 @@ class PluginRuntimeConnector: if not self.is_enable_plugin: return [] - return await self.handler.list_plugins() + plugins = await self.handler.list_plugins() + + # Sort plugins: debug plugins first, then by installation time (newest first) + # Get installation timestamps from database in a single query + plugin_timestamps = {} + + if plugins: + # Build list of (author, name) tuples for all plugins + plugin_ids = [] + for plugin in plugins: + author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '') + name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '') + if author and name: + plugin_ids.append((author, name)) + + # Fetch all timestamps in a single query using OR conditions + if plugin_ids: + conditions = [ + sqlalchemy.and_( + persistence_plugin.PluginSetting.plugin_author == author, + persistence_plugin.PluginSetting.plugin_name == name, + ) + for author, name in plugin_ids + ] + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select( + persistence_plugin.PluginSetting.plugin_author, + persistence_plugin.PluginSetting.plugin_name, + persistence_plugin.PluginSetting.created_at, + ).where(sqlalchemy.or_(*conditions)) + ) + + for row in result: + plugin_id = f'{row.plugin_author}/{row.plugin_name}' + plugin_timestamps[plugin_id] = row.created_at + + # Sort: debug plugins first (descending), then by created_at (descending) + def sort_key(plugin): + author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '') + name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '') + plugin_id = f'{author}/{name}' + + is_debug = plugin.get('debug', False) + created_at = plugin_timestamps.get(plugin_id) + + # Return tuple: (not is_debug, -timestamp) + # not is_debug: False (0) for debug plugins, True (1) for non-debug + # -timestamp: to sort newest first (will be None for plugins without timestamp) + timestamp_value = -created_at.timestamp() if created_at else 0 + return (not is_debug, timestamp_value) + + plugins.sort(key=sort_key) + + return plugins async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]: return await self.handler.get_plugin_info(author, plugin_name) diff --git a/tests/unit_tests/plugin/__init__.py b/tests/unit_tests/plugin/__init__.py new file mode 100644 index 00000000..2e00343b --- /dev/null +++ b/tests/unit_tests/plugin/__init__.py @@ -0,0 +1 @@ +# Plugin connector unit tests diff --git a/tests/unit_tests/plugin/test_plugin_list_sorting.py b/tests/unit_tests/plugin/test_plugin_list_sorting.py new file mode 100644 index 00000000..09fc173e --- /dev/null +++ b/tests/unit_tests/plugin/test_plugin_list_sorting.py @@ -0,0 +1,228 @@ +"""Test plugin list sorting functionality.""" + +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock +import pytest + + +@pytest.mark.asyncio +async def test_plugin_list_sorting_debug_first(): + """Test that debug plugins appear before non-debug plugins.""" + 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 debug states and timestamps + now = datetime.now() + mock_plugins = [ + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author1', + 'name': 'plugin1', + } + } + }, + }, + { + 'debug': True, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author2', + 'name': 'plugin2', + } + } + }, + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author3', + 'name': 'plugin3', + } + } + }, + }, + ] + + connector.handler.list_plugins = AsyncMock(return_value=mock_plugins) + + # Mock database query to return all timestamps in a single batch + async def mock_execute_async(query): + mock_result = MagicMock() + + # Create mock rows for all plugins with timestamps + mock_rows = [] + + # plugin1: oldest, plugin2: middle, plugin3: newest + mock_row1 = MagicMock() + mock_row1.plugin_author = 'author1' + mock_row1.plugin_name = 'plugin1' + mock_row1.created_at = now - timedelta(days=2) + mock_rows.append(mock_row1) + + mock_row2 = MagicMock() + mock_row2.plugin_author = 'author2' + mock_row2.plugin_name = 'plugin2' + mock_row2.created_at = now - timedelta(days=1) + mock_rows.append(mock_row2) + + mock_row3 = MagicMock() + mock_row3.plugin_author = 'author3' + mock_row3.plugin_name = 'plugin3' + mock_row3.created_at = now + mock_rows.append(mock_row3) + + # Make the result iterable + mock_result.__iter__ = lambda self: iter(mock_rows) + + return mock_result + + mock_app.persistence_mgr.execute_async = mock_execute_async + + # Call list_plugins + result = await connector.list_plugins() + + # Verify sorting: debug plugin should be first + assert len(result) == 3 + assert result[0]['debug'] is True # plugin2 (debug) + assert result[0]['manifest']['manifest']['metadata']['name'] == 'plugin2' + + # Remaining should be sorted by created_at (newest first) + assert result[1]['debug'] is False + assert result[1]['manifest']['manifest']['metadata']['name'] == 'plugin3' # newest non-debug + assert result[2]['debug'] is False + assert result[2]['manifest']['manifest']['metadata']['name'] == 'plugin1' # oldest non-debug + + +@pytest.mark.asyncio +async def test_plugin_list_sorting_by_installation_time(): + """Test that non-debug plugins are sorted by installation time (newest first).""" + 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 - all non-debug with different installation times + now = datetime.now() + mock_plugins = [ + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author1', + 'name': 'oldest_plugin', + } + } + }, + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author2', + 'name': 'middle_plugin', + } + } + }, + }, + { + 'debug': False, + 'manifest': { + 'manifest': { + 'metadata': { + 'author': 'author3', + 'name': 'newest_plugin', + } + } + }, + }, + ] + + connector.handler.list_plugins = AsyncMock(return_value=mock_plugins) + + # Mock database query to return all timestamps in a single batch + async def mock_execute_async(query): + mock_result = MagicMock() + + # Create mock rows for all plugins with timestamps + mock_rows = [] + + # oldest_plugin: oldest, middle_plugin: middle, newest_plugin: newest + mock_row1 = MagicMock() + mock_row1.plugin_author = 'author1' + mock_row1.plugin_name = 'oldest_plugin' + mock_row1.created_at = now - timedelta(days=10) + mock_rows.append(mock_row1) + + mock_row2 = MagicMock() + mock_row2.plugin_author = 'author2' + mock_row2.plugin_name = 'middle_plugin' + mock_row2.created_at = now - timedelta(days=5) + mock_rows.append(mock_row2) + + mock_row3 = MagicMock() + mock_row3.plugin_author = 'author3' + mock_row3.plugin_name = 'newest_plugin' + mock_row3.created_at = now + mock_rows.append(mock_row3) + + # Make the result iterable + mock_result.__iter__ = lambda self: iter(mock_rows) + + return mock_result + + mock_app.persistence_mgr.execute_async = mock_execute_async + + # Call list_plugins + result = await connector.list_plugins() + + # Verify sorting: newest first + assert len(result) == 3 + assert result[0]['manifest']['manifest']['metadata']['name'] == 'newest_plugin' + assert result[1]['manifest']['manifest']['metadata']['name'] == 'middle_plugin' + assert result[2]['manifest']['manifest']['metadata']['name'] == 'oldest_plugin' + + +@pytest.mark.asyncio +async def test_plugin_list_empty(): + """Test that empty plugin list is handled correctly.""" + 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 empty plugin list + connector.handler.list_plugins = AsyncMock(return_value=[]) + + # Call list_plugins + result = await connector.list_plugins() + + # Verify empty list + assert len(result) == 0 diff --git a/web/src/app/home/plugins/plugins.module.css b/web/src/app/home/plugins/plugins.module.css index a65be354..7ae07994 100644 --- a/web/src/app/home/plugins/plugins.module.css +++ b/web/src/app/home/plugins/plugins.module.css @@ -12,6 +12,7 @@ padding-left: 0.8rem; padding-right: 0.8rem; padding-top: 2rem; + padding-bottom: 2rem; display: grid; grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr)); gap: 2rem;