From fd50f3366280ef83188b5c968ec36117c5e0f034 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Tue, 12 May 2026 21:31:53 +0800 Subject: [PATCH] fix(plugin-runtime): propagate send capability failure details to tools --- pytests/test_plugin_runtime.py | 95 ++++++++++++++++++++++++ src/plugin_runtime/runner/runner_main.py | 25 ++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/pytests/test_plugin_runtime.py b/pytests/test_plugin_runtime.py index a2eac241..151cb20b 100644 --- a/pytests/test_plugin_runtime.py +++ b/pytests/test_plugin_runtime.py @@ -542,6 +542,101 @@ class TestSDK: assert await plugin.ctx.llm.get_available_models() == ["utils", "replyer"] + @pytest.mark.asyncio + async def test_runner_injected_context_raises_send_capability_error_details(self): + """Runner 应将 send.* 能力失败的底层错误透传为异常。""" + from src.plugin_runtime.runner.runner_main import PluginRunner + + class DummyRPCClient: + async def send_request(self, method, plugin_id="", payload=None, timeout_ms=30000): + assert method == "cap.call" + assert plugin_id == "owner_plugin" + assert payload == { + "capability": "send.custom", + "args": { + "message_type": "poke", + "content": {"qq_id": "1"}, + "custom_type": "poke", + "data": {"qq_id": "1"}, + "stream_id": "当前聊天流", + }, + } + return SimpleNamespace( + error=None, + payload={"success": True, "result": {"success": False, "error": "未找到聊天流: 当前聊天流"}}, + ) + + class DummyPlugin: + def _set_context(self, ctx): + self.ctx = ctx + + runner = PluginRunner(host_address="dummy", session_token="token", plugin_dirs=[]) + runner._rpc_client = DummyRPCClient() + + plugin = DummyPlugin() + runner._inject_context("owner_plugin", plugin) + + with pytest.raises(RuntimeError, match="未找到聊天流: 当前聊天流"): + await plugin.ctx.send.custom( + custom_type="poke", + data={"qq_id": "1"}, + stream_id="当前聊天流", + ) + + @pytest.mark.asyncio + async def test_runner_invoke_tool_propagates_send_failure_details(self): + """插件工具捕获 send.* 失败时,应能拿到底层错误详情。""" + from src.plugin_runtime.protocol.envelope import Envelope, MessageType + from src.plugin_runtime.runner.runner_main import PluginRunner + + class DummyRPCClient: + async def send_request(self, method, plugin_id="", payload=None, timeout_ms=30000): + assert method == "cap.call" + return SimpleNamespace( + error=None, + payload={"success": True, "result": {"success": False, "error": "未找到聊天流: 当前聊天流"}}, + ) + + class DummyPlugin: + def _set_context(self, ctx): + self.ctx = ctx + + async def handle_poke(self, **kwargs): + try: + await self.ctx.send.custom( + custom_type="poke", + data={"qq_id": "1"}, + stream_id=str(kwargs.get("stream_id", "")), + ) + except Exception as exc: + return {"success": False, "message": f"戳一戳失败: {exc}"} + return {"success": True, "message": "戳一戳成功"} + + runner = PluginRunner(host_address="dummy", session_token="token", plugin_dirs=[]) + runner._rpc_client = DummyRPCClient() + + plugin = DummyPlugin() + runner._inject_context("demo_plugin", plugin) + meta = SimpleNamespace( + plugin_id="demo_plugin", + instance=plugin, + component_handlers={"poke": "handle_poke"}, + ) + runner._loader._loaded_plugins["demo_plugin"] = meta + + envelope = Envelope( + request_id=1, + message_type=MessageType.REQUEST, + method="plugin.invoke_tool", + plugin_id="demo_plugin", + payload={"component_name": "poke", "args": {"stream_id": "当前聊天流"}}, + ) + + response = await runner._handle_invoke(envelope) + + assert response.payload["success"] is True + assert response.payload["result"] == {"success": False, "message": "戳一戳失败: 未找到聊天流: 当前聊天流"} + @pytest.mark.asyncio async def test_runner_applies_initial_plugin_config(self, tmp_path): """Runner 应在 on_load 前为支持的插件实例注入 config.toml。""" diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py index bc6f92be..b53cab8e 100644 --- a/src/plugin_runtime/runner/runner_main.py +++ b/src/plugin_runtime/runner/runner_main.py @@ -75,6 +75,18 @@ _PLUGIN_ALLOWED_RAW_HOST_METHODS = frozenset( } ) +_RAISE_ON_FAILED_CAPABILITIES = frozenset( + { + "send.command", + "send.custom", + "send.emoji", + "send.forward", + "send.hybrid", + "send.image", + "send.text", + } +) + class _ContextAwarePlugin(Protocol): """支持注入运行时上下文的插件协议。 @@ -518,7 +530,18 @@ class PluginRunner: if resp.error: raise RuntimeError(resp.error.get("message", "能力调用失败")) if normalized_method == "cap.call" and isinstance(resp.payload, dict) and "result" in resp.payload: - return resp.payload.get("result") + capability_result = resp.payload.get("result") + capability_name = str((payload or {}).get("capability", "")).strip() + if ( + capability_name in _RAISE_ON_FAILED_CAPABILITIES + and isinstance(capability_result, dict) + and capability_result.get("success") is False + ): + error_message = str( + capability_result.get("error") or capability_result.get("message") or "能力调用失败" + ).strip() + raise RuntimeError(error_message or "能力调用失败") + return capability_result return resp.payload ctx = PluginContext(plugin_id=plugin_id, rpc_call=_rpc_call)