diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 6c92745b..85e89d08 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -103,6 +103,79 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]: } +def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any: + """ + 将插件市场或本地仓库中的来源元数据合并到已安装插件对象。 + """ + repo_url = getattr(source_plugin, "repo_url", None) + if repo_url: + setattr(plugin, "repo_url", repo_url) + + for attr in ( + "has_update", + "release", + "system_version", + "system_version_compatible", + "system_version_message", + ): + value = getattr(source_plugin, attr, None) + if value is not None: + setattr(plugin, attr, value) + + return plugin + + +def _map_plugins_by_id(plugins: list[Any]) -> dict[str, Any]: + """ + 按插件 ID 建立稳定映射,保留同 ID 首个候选来源。 + """ + plugin_map: dict[str, Any] = {} + for plugin in plugins: + plugin_id = getattr(plugin, "id", None) + if plugin_id and plugin_id not in plugin_map: + plugin_map[plugin_id] = plugin + return plugin_map + + +async def enrich_installed_plugin_sources( + installed_plugins: list[Any], + force_refresh: bool = False, +) -> list[Any]: + """ + 为已安装插件补齐安装来源仓库地址。 + + 本地插件对象只包含运行目录中的静态元数据,通常没有 repo_url。这里按需从 + 本地插件仓库和插件市场补齐来源,保证 Agent 后续安装、升级判断可以拿到仓库地址。 + """ + missing_source_plugins = [ + plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None) + ] + if not missing_source_plugins: + return installed_plugins + + plugin_manager = PluginManager() + local_repo_map = _map_plugins_by_id(plugin_manager.get_local_repo_plugins()) + for plugin in missing_source_plugins: + source_plugin = local_repo_map.get(getattr(plugin, "id", None)) + if source_plugin: + _merge_plugin_source_metadata(plugin, source_plugin) + + missing_source_plugins = [ + plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None) + ] + if not missing_source_plugins: + return installed_plugins + + market_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh) + market_map = _map_plugins_by_id(market_plugins or []) + for plugin in missing_source_plugins: + source_plugin = market_map.get(getattr(plugin, "id", None)) + if source_plugin: + _merge_plugin_source_metadata(plugin, source_plugin) + + return installed_plugins + + async def load_market_plugins(force_refresh: bool = False) -> list[Any]: """ 聚合插件市场与本地插件仓库中的候选插件。 diff --git a/app/agent/tools/impl/query_installed_plugins.py b/app/agent/tools/impl/query_installed_plugins.py index 4e5da005..d9ce510f 100644 --- a/app/agent/tools/impl/query_installed_plugins.py +++ b/app/agent/tools/impl/query_installed_plugins.py @@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag from app.agent.tools.impl._plugin_tool_utils import ( DEFAULT_PLUGIN_CANDIDATE_LIMIT, MAX_PLUGIN_CANDIDATE_LIMIT, + enrich_installed_plugin_sources, list_installed_plugins, search_plugin_candidates, summarize_candidates, @@ -31,9 +32,15 @@ class QueryInstalledPluginsInput(BaseModel): DEFAULT_PLUGIN_CANDIDATE_LIMIT, description="Maximum number of plugins to return. Defaults to 50, capped at 200.", ) + force_refresh_market: bool = Field( + False, + description="Whether to refresh plugin market caches before completing missing repo_url values.", + ) class QueryInstalledPluginsTool(MoviePilotTool): + """查询已安装插件并返回 Agent 可消费的摘要信息。""" + name: str = "query_installed_plugins" tags: list[str] = [ ToolTag.Read, @@ -67,9 +74,15 @@ class QueryInstalledPluginsTool(MoviePilotTool): self, query: Optional[str] = None, max_results: Optional[int] = DEFAULT_PLUGIN_CANDIDATE_LIMIT, + force_refresh_market: bool = False, **kwargs, ) -> str: - logger.info(f"执行工具: {self.name}, 参数: query={query}") + """ + 查询已安装插件列表,并在可能时补齐插件来源仓库地址。 + """ + logger.info( + f"执行工具: {self.name}, 参数: query={query}, force_refresh_market={force_refresh_market}" + ) try: installed_plugins = list_installed_plugins() if not installed_plugins: @@ -77,6 +90,10 @@ class QueryInstalledPluginsTool(MoviePilotTool): {"success": False, "message": "当前没有已安装的插件"}, ensure_ascii=False, ) + installed_plugins = await enrich_installed_plugin_sources( + installed_plugins, + force_refresh=force_refresh_market, + ) limit = self._clamp_results(max_results) if query: diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 6911cfc4..52b3380c 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -194,55 +194,6 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所 } ``` -**`search_web` 网络搜索示例**: -```json -{ - "tool_name": "search_web", - "arguments": { - "query": "asyncio TaskGroup", - "search_engine": "duckduckgo", - "site_url": "https://docs.python.org/3/", - "max_results": 5 - } -} -``` - -`search_engine` 可选,通过 DDGS 支持 `auto`、`duckduckgo`、`google`、`brave`、`yahoo`、`wikipedia`、`yandex`、`mojeek`。`site_url` 可选,用于限定搜索到指定域名或 URL 路径范围。搜索默认使用系统代理配置。 - -**`browse_webpage` 浏览器操作示例**: -```json -{ - "tool_name": "browse_webpage", - "arguments": { - "action": "goto", - "url": "https://example.com" - } -} -``` - -`browse_webpage` 使用持久浏览器会话,默认以当前 Agent 会话作为 `session_key`。`goto`、`snapshot`、`click`、`click_ref`、`fill`、`fill_ref`、`select`、`select_ref`、`wait` 等动作会返回页面快照,快照中的 `interactive_elements[].ref` 可用于后续 `*_ref` 操作。支持 `list_tabs`、`open_tab`、`focus_tab`、`close_tab` 管理标签页,支持 `close_session` 释放会话。出于安全考虑,默认拒绝访问 localhost、环回地址、私网地址和链路本地地址;确需访问可信内网或本机页面时,可显式传入 `allow_private_network: true`。 - -**`recognize_captcha` 图形验证码识别示例**: -```json -{ - "tool_name": "recognize_captcha", - "arguments": { - "image_url": "https://example.com/captcha.png", - "cookie": "sid=...", - "user_agent": "Mozilla/5.0 ..." - } -} -``` - -`recognize_captcha` 用于浏览器自动化登录时识别普通图形验证码。智能体可先通过 `browse_webpage` 的 `evaluate` 动作从页面元素中提取 `img.src`,再把图片地址传给该工具;支持 `http/https` 图片地址和 `data:image/...;base64,...`。当验证码图片依赖当前浏览器会话时,可传入 Cookie 与 User-Agent。出于安全考虑,默认拒绝访问 localhost、环回地址、私网地址和链路本地地址;确需访问可信内网或本机验证码图片时,可显式传入 `allow_private_network: true`。 - -**下载任务工具说明**: - -- `add_download_tasks` 用于添加下载任务,支持 `get_search_results` 返回的 `hash:id` 引用和磁力链接,可指定下载器、保存目录和标签。 -- `query_download_tasks` 用于查询下载任务,支持按下载器、状态、Hash、标题、标签过滤;返回保存目录、内容路径、上传/下载速度、上传/下载限速、分类、分享率、做种时间等下载器可提供的字段。按 `hash` 查询或传入 `include_trackers=true` 时,会尽量返回 Tracker URL 列表。 -- `update_download_tasks` 用于修改下载任务,统一支持 `start`/`stop`、标签、上传/下载限速、Tracker、保存目录、分类、分享率、做种时间等字段;具体字段是否成功取决于下载器能力,返回结果会按操作项逐条标记成功或失败。 -- `delete_download_tasks` 用于删除下载任务,按任务 Hash 操作,可指定下载器,并可选择是否同时删除已下载文件。 - ### 3. 获取工具详情 **GET** `/api/v1/mcp/tools/{tool_name}` diff --git a/skills/create-moviepilot-plugin/SKILL.md b/skills/create-moviepilot-plugin/SKILL.md index 78b12090..4c5fd370 100644 --- a/skills/create-moviepilot-plugin/SKILL.md +++ b/skills/create-moviepilot-plugin/SKILL.md @@ -59,6 +59,9 @@ a local plugin source and installed into the running MoviePilot instance. - Local runtime examples: `app/plugins//__init__.py` - Market/local source candidates: use `query_market_plugins` when the running instance is available. + - Installed plugin candidates: use `query_installed_plugins`; its summaries + include `repo_url` when the source can be matched from a local plugin + repository or plugin market metadata. - For Vue federation examples, prefer current compliant plugins such as `MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example `MoviePilot-Frontend/examples/plugin-component/`. diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md index dc20745a..e9f06e25 100644 --- a/skills/feedback-issue/SKILL.md +++ b/skills/feedback-issue/SKILL.md @@ -5,7 +5,7 @@ description: >- Use this skill ONLY when the user EXPLICITLY requests filing an upstream issue for MoviePilot core, frontend, or an installed plugin, for example "反馈 issue", "提 issue", "报 bug", "给 MP 提 issue", - "让上游修一下", "提交错误报告", "提需求", "功能请求", + "让上游修一下", "提交错误报告", "提问题", "提需求", "功能请求", or English "file an issue / report a bug / open an upstream issue / feature request". A bare problem report is not enough: diagnose locally first. This @@ -242,9 +242,15 @@ python /scripts/submit_feedback_issue.py \ --username "" ``` -The script creates the GitHub issue through `GITHUB_TOKEN` when the -token is configured and has permission. Otherwise it returns a -`prefill_url`. Relay the result: +The script automatically imports MoviePilot's `app.core.config.settings` +and reads the system-configured `GITHUB_TOKEN` / `settings.GITHUB_HEADERS` +from the running MoviePilot environment. Do not ask the user to provide +a GitHub token in chat, and never accept or echo a token from the user. +When that configured token exists and has permission, the script creates +the GitHub issue through the GitHub API. Otherwise it returns a +`prefill_url`. + +Relay the result: - `success=true`: tell the user the issue was submitted and include `issue_url` if present. diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index d6127995..a2c9789c 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -1,11 +1,11 @@ import asyncio import json -import unittest from types import SimpleNamespace +from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch -from app.agent.tools.impl.install_plugin import InstallPluginTool from app.agent.tools.impl._plugin_tool_utils import install_plugin_runtime +from app.agent.tools.impl.install_plugin import InstallPluginTool from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool @@ -15,254 +15,361 @@ from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool -class TestAgentPluginTools(unittest.TestCase): - @staticmethod - def _plugin_snapshot(state: bool = True) -> dict: - return { - "plugin_id": "DemoPlugin", - "plugin_name": "Demo Plugin", - "plugin_version": "1.0.0", - "state": state, - } +def _plugin_snapshot(state: bool = True) -> dict: + """ + 构造插件运行态快照。 + """ + return { + "plugin_id": "DemoPlugin", + "plugin_name": "Demo Plugin", + "plugin_version": "1.0.0", + "state": state, + } - @staticmethod - def _market_plugin(plugin_id: str, plugin_name: str, installed: bool = False): - return SimpleNamespace( - id=plugin_id, - plugin_name=plugin_name, - plugin_desc=f"{plugin_name} description", - plugin_version="1.0.0", - plugin_author="author", - installed=installed, - has_update=False, - state=installed, - repo_url="https://example.com/market", - add_time=1, - ) - def test_query_market_plugins_filters_candidates(self): - tool = QueryMarketPluginsTool(session_id="session-1", user_id="10001") - plugins = [ - self._market_plugin("DemoPlugin", "Demo Plugin"), - self._market_plugin("OtherPlugin", "Other Plugin"), - ] +def _market_plugin( + plugin_id: str, + plugin_name: str, + installed: bool = False, + repo_url: Optional[str] = "https://example.com/market", +) -> SimpleNamespace: + """ + 构造插件市场或已安装插件摘要对象。 + """ + return SimpleNamespace( + id=plugin_id, + plugin_name=plugin_name, + plugin_desc=f"{plugin_name} description", + plugin_version="1.0.0", + plugin_author="author", + installed=installed, + has_update=False, + state=installed, + repo_url=repo_url, + add_time=1, + ) - with patch( - "app.agent.tools.impl.query_market_plugins.load_market_plugins", - new=AsyncMock(return_value=plugins), - ): - result = asyncio.run(tool.run(query="demo")) - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertEqual(payload["match_count"], 1) - self.assertEqual(payload["plugins"][0]["id"], "DemoPlugin") +def test_query_market_plugins_filters_candidates() -> None: + """ + 查询插件市场时会按关键字返回匹配候选。 + """ + tool = QueryMarketPluginsTool(session_id="session-1", user_id="10001") + plugins = [ + _market_plugin("DemoPlugin", "Demo Plugin"), + _market_plugin("OtherPlugin", "Other Plugin"), + ] - def test_query_installed_plugins_filters_candidates(self): - tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001") - plugins = [ - self._market_plugin("DemoPlugin", "Demo Plugin", installed=True), - self._market_plugin("OtherPlugin", "Other Plugin", installed=True), - ] + with patch( + "app.agent.tools.impl.query_market_plugins.load_market_plugins", + new=AsyncMock(return_value=plugins), + ): + result = asyncio.run(tool.run(query="demo")) - with patch( + payload = json.loads(result) + assert payload["success"] + assert payload["match_count"] == 1 + assert payload["plugins"][0]["id"] == "DemoPlugin" + + +def test_query_installed_plugins_filters_candidates() -> None: + """ + 查询已安装插件时会按关键字返回匹配候选。 + """ + tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001") + plugins = [ + _market_plugin("DemoPlugin", "Demo Plugin", installed=True), + _market_plugin("OtherPlugin", "Other Plugin", installed=True), + ] + + with patch( + "app.agent.tools.impl.query_installed_plugins.list_installed_plugins", + return_value=plugins, + ): + result = asyncio.run(tool.run(query="demo")) + + payload = json.loads(result) + assert payload["success"] + assert payload["match_count"] == 1 + assert payload["plugins"][0]["id"] == "DemoPlugin" + + +def test_query_installed_plugins_fills_missing_repo_url_from_market() -> None: + """ + 已安装插件缺少来源地址时,会从插件市场元数据补齐 repo_url。 + """ + tool = QueryInstalledPluginsTool(session_id="session-1", user_id="10001") + installed_plugin = _market_plugin( + "DemoPlugin", "Demo Plugin", installed=True, repo_url=None + ) + market_plugin = _market_plugin( + "DemoPlugin", + "Demo Plugin", + installed=True, + repo_url="https://github.com/demo/plugins", + ) + plugin_manager = MagicMock() + plugin_manager.get_local_repo_plugins.return_value = [] + plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) + + with ( + patch( "app.agent.tools.impl.query_installed_plugins.list_installed_plugins", - return_value=plugins, - ): - result = asyncio.run(tool.run(query="demo")) - - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertEqual(payload["match_count"], 1) - self.assertEqual(payload["plugins"][0]["id"], "DemoPlugin") - - def test_query_plugin_config_returns_saved_config_and_default_model(self): - tool = QueryPluginConfigTool(session_id="session-1", user_id="10001") - plugin_manager = MagicMock() - plugin_manager.get_plugin_config.return_value = {"enabled": True} - plugin_instance = MagicMock() - plugin_instance.get_form.return_value = (None, {"enabled": False, "interval": 10}) - plugin_manager.running_plugins = {"DemoPlugin": plugin_instance} - - with patch( - "app.agent.tools.impl.query_plugin_config.get_plugin_snapshot", - return_value=self._plugin_snapshot(), - ), patch( - "app.agent.tools.impl.query_plugin_config.PluginManager", - return_value=plugin_manager, - ): - result = asyncio.run(tool.run(plugin_id="DemoPlugin")) - - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertEqual(payload["config"], {"enabled": True}) - self.assertEqual(payload["default_model"], {"enabled": False, "interval": 10}) - - def test_update_plugin_config_merges_and_removes_keys_without_reloading(self): - tool = UpdatePluginConfigTool(session_id="session-1", user_id="10001") - plugin_manager = MagicMock() - plugin_manager.get_plugin_config.return_value = { - "enabled": False, - "interval": 30, - "token": "legacy-token", - } - plugin_manager.async_save_plugin_config = AsyncMock(return_value=True) - - with patch( - "app.agent.tools.impl.update_plugin_config.get_plugin_snapshot", - return_value=self._plugin_snapshot(), - ), patch( - "app.agent.tools.impl.update_plugin_config.PluginManager", - return_value=plugin_manager, - ): - result = asyncio.run( - tool.run( - plugin_id="DemoPlugin", - updates={"enabled": True}, - remove_keys=["token"], - ) - ) - - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertTrue(payload["config_requires_reload"]) - self.assertEqual(payload["saved_config"], {"enabled": True, "interval": 30}) - plugin_manager.async_save_plugin_config.assert_awaited_once_with( - "DemoPlugin", - {"enabled": True, "interval": 30}, - ) - - def test_reload_plugin_triggers_runtime_refresh(self): - tool = ReloadPluginTool(session_id="session-1", user_id="10001") - - with patch( - "app.agent.tools.impl.reload_plugin.get_plugin_snapshot", - side_effect=[self._plugin_snapshot(), self._plugin_snapshot(state=False)], - ), patch( - "app.agent.tools.impl.reload_plugin.reload_plugin_runtime" - ) as reload_plugin_runtime: - result = asyncio.run(tool.run(plugin_id="DemoPlugin")) - - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertFalse(payload["state"]) - reload_plugin_runtime.assert_called_once_with("DemoPlugin") - - def test_install_plugin_installs_market_candidate(self): - tool = InstallPluginTool(session_id="session-1", user_id="10001") - candidate = self._market_plugin("DemoPlugin", "Demo Plugin") - - with patch( - "app.agent.tools.impl.install_plugin.load_market_plugins", - new=AsyncMock(return_value=[candidate]), - ), patch( - "app.agent.tools.impl.install_plugin.install_plugin_runtime", - new=AsyncMock(return_value=(True, "插件安装完成", False)), - ) as install_runtime, patch( - "app.agent.tools.impl.install_plugin.get_plugin_snapshot", - return_value=self._plugin_snapshot(), - ): - result = asyncio.run(tool.run(plugin_id="DemoPlugin")) - - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertEqual(payload["plugin"]["id"], "DemoPlugin") - install_runtime.assert_awaited_once_with( - "DemoPlugin", "https://example.com/market", force=False - ) - - def test_install_plugin_runtime_reloads_in_threadpool(self): - plugin_manager = MagicMock() - plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"] - plugin_helper = MagicMock() - config_oper = MagicMock() - config_oper.get.return_value = ["DemoPlugin"] - calls = [] - - async def fake_run_agent_blocking(bucket, func, *args, **kwargs): - calls.append((bucket, func, args, kwargs)) - return None - - with patch( - "app.agent.tools.impl._plugin_tool_utils.SystemConfigOper", - return_value=config_oper, - ), patch( + return_value=[installed_plugin], + ), + patch( "app.agent.tools.impl._plugin_tool_utils.PluginManager", return_value=plugin_manager, - ), patch( + ), + ): + result = asyncio.run(tool.run(query="demo")) + + payload = json.loads(result) + assert payload["success"] + assert payload["plugins"][0]["repo_url"] == "https://github.com/demo/plugins" + plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False) + + +def test_query_plugin_config_returns_saved_config_and_default_model() -> None: + """ + 查询插件配置会返回保存值和默认配置模型。 + """ + tool = QueryPluginConfigTool(session_id="session-1", user_id="10001") + plugin_manager = MagicMock() + plugin_manager.get_plugin_config.return_value = {"enabled": True} + plugin_instance = MagicMock() + plugin_instance.get_form.return_value = (None, {"enabled": False, "interval": 10}) + plugin_manager.running_plugins = {"DemoPlugin": plugin_instance} + + with ( + patch( + "app.agent.tools.impl.query_plugin_config.get_plugin_snapshot", + return_value=_plugin_snapshot(), + ), + patch( + "app.agent.tools.impl.query_plugin_config.PluginManager", + return_value=plugin_manager, + ), + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + + payload = json.loads(result) + assert payload["success"] + assert payload["config"] == {"enabled": True} + assert payload["default_model"] == {"enabled": False, "interval": 10} + + +def test_update_plugin_config_merges_and_removes_keys_without_reloading() -> None: + """ + 更新插件配置会合并新增键并移除指定旧键。 + """ + tool = UpdatePluginConfigTool(session_id="session-1", user_id="10001") + plugin_manager = MagicMock() + plugin_manager.get_plugin_config.return_value = { + "enabled": False, + "interval": 30, + "token": "legacy-token", + } + plugin_manager.async_save_plugin_config = AsyncMock(return_value=True) + + with ( + patch( + "app.agent.tools.impl.update_plugin_config.get_plugin_snapshot", + return_value=_plugin_snapshot(), + ), + patch( + "app.agent.tools.impl.update_plugin_config.PluginManager", + return_value=plugin_manager, + ), + ): + result = asyncio.run( + tool.run( + plugin_id="DemoPlugin", + updates={"enabled": True}, + remove_keys=["token"], + ) + ) + + payload = json.loads(result) + assert payload["success"] + assert payload["config_requires_reload"] + assert payload["saved_config"] == {"enabled": True, "interval": 30} + plugin_manager.async_save_plugin_config.assert_awaited_once_with( + "DemoPlugin", + {"enabled": True, "interval": 30}, + ) + + +def test_reload_plugin_triggers_runtime_refresh() -> None: + """ + 重载插件工具会调用运行态刷新流程。 + """ + tool = ReloadPluginTool(session_id="session-1", user_id="10001") + + with ( + patch( + "app.agent.tools.impl.reload_plugin.get_plugin_snapshot", + side_effect=[_plugin_snapshot(), _plugin_snapshot(state=False)], + ), + patch( + "app.agent.tools.impl.reload_plugin.reload_plugin_runtime" + ) as reload_plugin_runtime, + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + + payload = json.loads(result) + assert payload["success"] + assert not payload["state"] + reload_plugin_runtime.assert_called_once_with("DemoPlugin") + + +def test_install_plugin_installs_market_candidate() -> None: + """ + 安装插件工具会使用市场候选携带的仓库地址。 + """ + tool = InstallPluginTool(session_id="session-1", user_id="10001") + candidate = _market_plugin("DemoPlugin", "Demo Plugin") + + with ( + patch( + "app.agent.tools.impl.install_plugin.load_market_plugins", + new=AsyncMock(return_value=[candidate]), + ), + patch( + "app.agent.tools.impl.install_plugin.install_plugin_runtime", + new=AsyncMock(return_value=(True, "插件安装完成", False)), + ) as install_runtime, + patch( + "app.agent.tools.impl.install_plugin.get_plugin_snapshot", + return_value=_plugin_snapshot(), + ), + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + + payload = json.loads(result) + assert payload["success"] + assert payload["plugin"]["id"] == "DemoPlugin" + install_runtime.assert_awaited_once_with( + "DemoPlugin", "https://example.com/market", force=False + ) + + +def test_install_plugin_runtime_reloads_in_threadpool() -> None: + """ + 已存在插件刷新加载时会通过插件线程池执行重载。 + """ + plugin_manager = MagicMock() + plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"] + plugin_helper = MagicMock() + config_oper = MagicMock() + config_oper.get.return_value = ["DemoPlugin"] + calls = [] + + async def fake_run_agent_blocking(bucket, func, *args, **kwargs) -> None: + calls.append((bucket, func, args, kwargs)) + return None + + with ( + patch( + "app.agent.tools.impl._plugin_tool_utils.SystemConfigOper", + return_value=config_oper, + ), + patch( + "app.agent.tools.impl._plugin_tool_utils.PluginManager", + return_value=plugin_manager, + ), + patch( "app.agent.tools.impl._plugin_tool_utils.PluginHelper", return_value=plugin_helper, - ), patch( + ), + patch( "app.agent.tools.impl._plugin_tool_utils.reload_plugin_runtime", - ) as reload_runtime, patch( + ) as reload_runtime, + patch( "app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg", AsyncMock(return_value=True), - ) as install_reg, patch( + ) as install_reg, + patch( "app.agent.tools.base.run_agent_blocking", side_effect=fake_run_agent_blocking, - ): - success, message, refreshed_only = asyncio.run( - install_plugin_runtime( - "DemoPlugin", - "https://example.com/market", - force=False, - ) + ), + ): + success, message, refreshed_only = asyncio.run( + install_plugin_runtime( + "DemoPlugin", + "https://example.com/market", + force=False, ) - - self.assertTrue(success) - self.assertEqual("插件已存在,已刷新加载", message) - self.assertTrue(refreshed_only) - install_reg.assert_awaited_once_with( - plugin_id="DemoPlugin", - repo_url="https://example.com/market", - ) - self.assertEqual(1, len(calls)) - self.assertEqual("plugin", calls[0][0]) - self.assertEqual(reload_runtime, calls[0][1]) - self.assertEqual(("DemoPlugin",), calls[0][2]) - self.assertEqual({}, calls[0][3]) - - def test_uninstall_plugin_uninstalls_installed_candidate(self): - tool = UninstallPluginTool(session_id="session-1", user_id="10001") - installed_plugin = self._market_plugin( - "DemoPlugin", "Demo Plugin", installed=True ) - with patch( + assert success + assert message == "插件已存在,已刷新加载" + assert refreshed_only + install_reg.assert_awaited_once_with( + plugin_id="DemoPlugin", + repo_url="https://example.com/market", + ) + assert len(calls) == 1 + assert calls[0][0] == "plugin" + assert calls[0][1] == reload_runtime + assert calls[0][2] == ("DemoPlugin",) + assert calls[0][3] == {} + + +def test_uninstall_plugin_uninstalls_installed_candidate() -> None: + """ + 卸载插件工具会按已安装候选执行卸载流程。 + """ + tool = UninstallPluginTool(session_id="session-1", user_id="10001") + installed_plugin = _market_plugin( + "DemoPlugin", "Demo Plugin", installed=True + ) + + with ( + patch( "app.agent.tools.impl.uninstall_plugin.list_installed_plugins", return_value=[installed_plugin], - ), patch( + ), + patch( "app.agent.tools.impl.uninstall_plugin.uninstall_plugin_runtime", new=AsyncMock( return_value={"was_clone": False, "clone_files_removed": False} ), - ) as uninstall_runtime: - result = asyncio.run(tool.run(plugin_id="DemoPlugin")) + ) as uninstall_runtime, + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin")) - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertEqual(payload["plugin"]["id"], "DemoPlugin") - uninstall_runtime.assert_awaited_once_with("DemoPlugin") + payload = json.loads(result) + assert payload["success"] + assert payload["plugin"]["id"] == "DemoPlugin" + uninstall_runtime.assert_awaited_once_with("DemoPlugin") - def test_query_plugin_data_truncates_large_payload(self): - tool = QueryPluginDataTool(session_id="session-1", user_id="10001") - plugin_data_oper = MagicMock() - plugin_data_oper.async_get_data_all = AsyncMock(return_value=[ - SimpleNamespace(key="payload", value={"text": "x" * 5000}) - ]) - with patch( +def test_query_plugin_data_truncates_large_payload() -> None: + """ + 查询插件数据会截断超长内容并返回预览。 + """ + tool = QueryPluginDataTool(session_id="session-1", user_id="10001") + plugin_data_oper = MagicMock() + plugin_data_oper.async_get_data_all = AsyncMock(return_value=[ + SimpleNamespace(key="payload", value={"text": "x" * 5000}) + ]) + + with ( + patch( "app.agent.tools.impl.query_plugin_data.get_plugin_snapshot", - return_value=self._plugin_snapshot(), - ), patch( + return_value=_plugin_snapshot(), + ), + patch( "app.agent.tools.impl.query_plugin_data.PluginDataOper", return_value=plugin_data_oper, - ): - result = asyncio.run(tool.run(plugin_id="DemoPlugin", max_chars=200)) + ), + ): + result = asyncio.run(tool.run(plugin_id="DemoPlugin", max_chars=200)) - payload = json.loads(result) - self.assertTrue(payload["success"]) - self.assertTrue(payload["truncated"]) - self.assertIn("data_preview", payload) - self.assertNotIn("data", payload) - self.assertIn("已截断", payload["data_preview"]) + payload = json.loads(result) + assert payload["success"] + assert payload["truncated"] + assert "data_preview" in payload + assert "data" not in payload + assert "已截断" in payload["data_preview"]