From 29f4d05a87d0a82207ffb3ab366e50cfe9ef4b24 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Fri, 13 Mar 2026 16:15:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=90=AF=E5=81=9C=E7=9B=AE=E6=A0=87=E8=A7=A3=E6=9E=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=85=A8=E5=B1=80=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E7=9F=AD=E5=90=8D=EF=BC=8C=E9=81=BF=E5=85=8D=E8=B7=A8?= =?UTF-8?q?=20Supervisor=20=E8=AF=AF=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pytests/test_plugin_runtime.py | 62 ++++++++++++++++++++++++++- src/plugin_runtime/integration.py | 71 ++++++++++++++++++++----------- 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/pytests/test_plugin_runtime.py b/pytests/test_plugin_runtime.py index 9c71bcea..309b69aa 100644 --- a/pytests/test_plugin_runtime.py +++ b/pytests/test_plugin_runtime.py @@ -1696,6 +1696,66 @@ class TestSupervisor: class TestIntegration: """运行时集成层启动/清理测试""" + @pytest.mark.asyncio + async def test_component_enable_rejects_ambiguous_short_name(self, monkeypatch): + from src.plugin_runtime import integration as integration_module + from src.plugin_runtime.host.component_registry import ComponentRegistry + + class FakeSupervisor: + def __init__(self, plugin_id: str): + self.component_registry = ComponentRegistry() + self.component_registry.register_component( + name="shared", + component_type="tool", + plugin_id=plugin_id, + metadata={}, + ) + + class FakeManager: + def __init__(self): + self.supervisors = [FakeSupervisor("plugin_a"), FakeSupervisor("plugin_b")] + + monkeypatch.setattr(integration_module, "get_plugin_runtime_manager", lambda: FakeManager()) + + result = await integration_module.PluginRuntimeManager._cap_component_enable( + "plugin_a", + "component.enable", + {"name": "shared", "component_type": "tool", "scope": "global", "stream_id": ""}, + ) + + assert result["success"] is False + assert "组件名不唯一" in result["error"] + + @pytest.mark.asyncio + async def test_component_disable_rejects_non_global_scope(self, monkeypatch): + from src.plugin_runtime import integration as integration_module + from src.plugin_runtime.host.component_registry import ComponentRegistry + + class FakeSupervisor: + def __init__(self): + self.component_registry = ComponentRegistry() + self.component_registry.register_component( + name="handler", + component_type="tool", + plugin_id="plugin_a", + metadata={}, + ) + + class FakeManager: + def __init__(self): + self.supervisors = [FakeSupervisor()] + + monkeypatch.setattr(integration_module, "get_plugin_runtime_manager", lambda: FakeManager()) + + result = await integration_module.PluginRuntimeManager._cap_component_disable( + "plugin_a", + "component.disable", + {"name": "plugin_a.handler", "component_type": "tool", "scope": "stream", "stream_id": "s1"}, + ) + + assert result["success"] is False + assert "仅支持全局组件禁用" in result["error"] + @pytest.mark.asyncio async def test_start_cleans_up_started_supervisors_on_failure(self, monkeypatch): from src.plugin_runtime import integration as integration_module @@ -1740,8 +1800,6 @@ class TestIntegration: @pytest.mark.asyncio async def test_handle_plugin_file_changes_routes_reload_and_config_update(self, monkeypatch, tmp_path): - from pathlib import Path - from src.config.file_watcher import FileChange from src.plugin_runtime import integration as integration_module diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py index e8d25eaf..8e4c2cf4 100644 --- a/src/plugin_runtime/integration.py +++ b/src/plugin_runtime/integration.py @@ -1652,6 +1652,31 @@ class PluginRuntimeManager: plugins.extend(sv._registered_plugins.keys()) return {"success": True, "plugins": plugins} + @staticmethod + def _resolve_component_toggle_target(name: str, component_type: str) -> tuple[Optional[Any], Optional[str]]: + """解析组件启停目标。 + + 支持全名 plugin_id.component_name;短名仅在全局唯一时允许, + 否则返回歧义错误,避免跨 Supervisor 误操作。 + """ + mgr = get_plugin_runtime_manager() + short_name_matches: List[Any] = [] + + for sv in mgr.supervisors: + comp = sv.component_registry.get_component(name) + if comp is not None and comp.component_type == component_type: + return comp, None + + for candidate in sv.component_registry.get_components_by_type(component_type, enabled_only=False): + if candidate.name == name: + short_name_matches.append(candidate) + + if len(short_name_matches) == 1: + return short_name_matches[0], None + if len(short_name_matches) > 1: + return None, f"组件名不唯一: {name} ({component_type}),请使用完整名 plugin_id.component_name" + return None, f"未找到组件: {name} ({component_type})" + @staticmethod async def _cap_component_enable(plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: """启用组件 @@ -1660,23 +1685,19 @@ class PluginRuntimeManager: """ name: str = args.get("name", "") component_type: str = args.get("component_type", "") + scope: str = args.get("scope", "global") + stream_id: str = args.get("stream_id", "") if not name or not component_type: return {"success": False, "error": "缺少必要参数 name 或 component_type"} + if scope != "global" or stream_id: + return {"success": False, "error": "当前仅支持全局组件启用,不支持 scope/stream_id 定位"} - # TODO: scope 和 stream_id 参数尚未实现,当前均为全局启用 - mgr = get_plugin_runtime_manager() - for sv in mgr.supervisors: - # 先尝试按全名查找(plugin_id.component_name) - comp = sv.component_registry.get_component(name) - if comp is not None and comp.component_type == component_type: - comp.enabled = True - return {"success": True} - # 回退:按短名 + 类型在该类型索引中搜索 - for c in sv.component_registry.get_components_by_type(component_type, enabled_only=False): - if c.name == name: - c.enabled = True - return {"success": True} - return {"success": False, "error": f"未找到组件: {name} ({component_type})"} + comp, error = PluginRuntimeManager._resolve_component_toggle_target(name, component_type) + if comp is None: + return {"success": False, "error": error or f"未找到组件: {name} ({component_type})"} + + comp.enabled = True + return {"success": True} @staticmethod async def _cap_component_disable(plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: @@ -1686,21 +1707,19 @@ class PluginRuntimeManager: """ name: str = args.get("name", "") component_type: str = args.get("component_type", "") + scope: str = args.get("scope", "global") + stream_id: str = args.get("stream_id", "") if not name or not component_type: return {"success": False, "error": "缺少必要参数 name 或 component_type"} + if scope != "global" or stream_id: + return {"success": False, "error": "当前仅支持全局组件禁用,不支持 scope/stream_id 定位"} - # TODO: scope 和 stream_id 参数尚未实现,当前均为全局禁用 - mgr = get_plugin_runtime_manager() - for sv in mgr.supervisors: - comp = sv.component_registry.get_component(name) - if comp is not None and comp.component_type == component_type: - comp.enabled = False - return {"success": True} - for c in sv.component_registry.get_components_by_type(component_type, enabled_only=False): - if c.name == name: - c.enabled = False - return {"success": True} - return {"success": False, "error": f"未找到组件: {name} ({component_type})"} + comp, error = PluginRuntimeManager._resolve_component_toggle_target(name, component_type) + if comp is None: + return {"success": False, "error": error or f"未找到组件: {name} ({component_type})"} + + comp.enabled = False + return {"success": True} @staticmethod async def _cap_component_load_plugin(plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: