From 3ba9b2452243c339b4ca05fa907f814f36293479 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 13 Mar 2026 06:45:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=92=8C=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=97=A7?= =?UTF-8?q?=E7=89=88=E6=8F=92=E4=BB=B6=E5=85=BC=E5=AE=B9=E6=80=A7=E5=8F=8A?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E8=A3=85=E9=A5=B0=E5=99=A8=E7=9A=84=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + CLAUDE.md | 2 + src-new/astrbot_sdk/decorators.py | 3 + tests_v4/test_decorators.py | 35 ++++++++++++ tests_v4/test_legacy_plugin_integration.py | 2 +- tests_v4/test_peer.py | 65 +++++++++++++++++++++- tests_v4/test_runtime.py | 2 +- 7 files changed, 108 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4972d536d..a02c7846f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ - 2026-03-13: `runtime.loader` must preserve declared legacy handler order. Falling back to `dir(instance)` or sorting the merged discoverable names reorders compat handlers alphabetically, which changes which legacy command/hook appears first to the supervisor and breaks old-plugin expectations. - 2026-03-13: `runtime.loader.import_string()` cannot trust `sys.modules` when plugins reuse generic top-level package names like `commands.*`. Before importing a plugin module, compare the cached root package against the current plugin directory and evict conflicting root/submodules, or later plugins will accidentally reuse an earlier plugin's package tree. - 2026-03-13: Keep `astrbot_sdk.protocol` root focused on native v4 protocol models and parsers. Legacy JSON-RPC helpers remain supported, but they should be imported from `astrbot_sdk.protocol.legacy_adapter` explicitly instead of being re-exported from the package root. +- 2026-03-13: `Peer` must treat transport EOF/connection loss as a first-class failure path, not only explicit protocol parse errors. If the transport closes unexpectedly and `Peer` does not proactively fail `_pending_results` / `_pending_streams`, supervisor-side calls into workers can hang forever even though the worker session already noticed the disconnect. +- 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index 4972d536d..a02c7846f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ - 2026-03-13: `runtime.loader` must preserve declared legacy handler order. Falling back to `dir(instance)` or sorting the merged discoverable names reorders compat handlers alphabetically, which changes which legacy command/hook appears first to the supervisor and breaks old-plugin expectations. - 2026-03-13: `runtime.loader.import_string()` cannot trust `sys.modules` when plugins reuse generic top-level package names like `commands.*`. Before importing a plugin module, compare the cached root package against the current plugin directory and evict conflicting root/submodules, or later plugins will accidentally reuse an earlier plugin's package tree. - 2026-03-13: Keep `astrbot_sdk.protocol` root focused on native v4 protocol models and parsers. Legacy JSON-RPC helpers remain supported, but they should be imported from `astrbot_sdk.protocol.legacy_adapter` explicitly instead of being re-exported from the package root. +- 2026-03-13: `Peer` must treat transport EOF/connection loss as a first-class failure path, not only explicit protocol parse errors. If the transport closes unexpectedly and `Peer` does not proactively fail `_pending_results` / `_pending_streams`, supervisor-side calls into workers can hang forever even though the worker session already noticed the disconnect. +- 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail. # 开发命令 diff --git a/src-new/astrbot_sdk/decorators.py b/src-new/astrbot_sdk/decorators.py index 73f8f0480..d644ed217 100644 --- a/src-new/astrbot_sdk/decorators.py +++ b/src-new/astrbot_sdk/decorators.py @@ -15,6 +15,7 @@ from .protocol.descriptors import ( EventTrigger, MessageTrigger, Permissions, + RESERVED_CAPABILITY_PREFIXES, ScheduleTrigger, ) @@ -131,6 +132,8 @@ def provide_capability( """声明插件对外暴露的 capability。""" def decorator(func: HandlerCallable) -> HandlerCallable: + if name.startswith(RESERVED_CAPABILITY_PREFIXES): + raise ValueError(f"保留 capability 命名空间不能用于插件导出:{name}") descriptor = CapabilityDescriptor( name=name, description=description, diff --git a/tests_v4/test_decorators.py b/tests_v4/test_decorators.py index 2edf7382d..5de3ef916 100644 --- a/tests_v4/test_decorators.py +++ b/tests_v4/test_decorators.py @@ -4,8 +4,10 @@ Tests for decorators.py - Handler decorator infrastructure. from __future__ import annotations +import pytest from astrbot_sdk.decorators import ( + get_capability_meta, HANDLER_META_ATTR, HandlerMeta, get_handler_meta, @@ -13,6 +15,7 @@ from astrbot_sdk.decorators import ( on_event, on_message, on_schedule, + provide_capability, require_admin, ) from astrbot_sdk.protocol.descriptors import ( @@ -260,6 +263,38 @@ class TestRequireAdminDecorator: meta = get_handler_meta(handler) assert meta.permissions.require_admin is True + +class TestProvideCapabilityDecorator: + """Tests for @provide_capability decorator.""" + + def test_sets_capability_meta(self): + """@provide_capability should attach capability descriptor metadata.""" + + @provide_capability( + "demo.echo", + description="Echo text", + input_schema={"type": "object"}, + output_schema={"type": "object"}, + ) + async def echo(payload): + return payload + + meta = get_capability_meta(echo) + assert meta is not None + assert meta.descriptor.name == "demo.echo" + + def test_rejects_reserved_namespaces(self): + """@provide_capability should reject framework-reserved prefixes.""" + for name in ("handler.echo", "system.echo", "internal.echo"): + with pytest.raises(ValueError, match=name): + + @provide_capability( + name, + description="reserved", + ) + async def reserved(payload): + return payload + def test_can_combine_with_other_decorators(self): """@require_admin can be combined with other decorators.""" diff --git a/tests_v4/test_legacy_plugin_integration.py b/tests_v4/test_legacy_plugin_integration.py index 4b1a24f0f..f625bf6d1 100644 --- a/tests_v4/test_legacy_plugin_integration.py +++ b/tests_v4/test_legacy_plugin_integration.py @@ -470,7 +470,7 @@ class TestRealTestPlugin: def test_load_test_plugin(self): """测试加载项目中的 test_plugin。""" project_root = Path(__file__).parent.parent - test_plugin_dir = project_root / "test_plugin" + test_plugin_dir = project_root / "test_plugin" / "old" if not test_plugin_dir.exists(): pytest.skip("test_plugin directory not found") diff --git a/tests_v4/test_peer.py b/tests_v4/test_peer.py index 48314e8df..449f1b3f0 100644 --- a/tests_v4/test_peer.py +++ b/tests_v4/test_peer.py @@ -19,7 +19,24 @@ from astrbot_sdk.runtime.transport import ( WebSocketServerTransport, ) -from tests_v4.helpers import make_transport_pair +from tests_v4.helpers import MemoryTransport, make_transport_pair + + +class LinkedMemoryTransport(MemoryTransport): + async def stop(self) -> None: + if self._closed.is_set(): + return + self._closed.set() + if self.partner is not None and not self.partner._closed.is_set(): + self.partner._closed.set() + + +def make_linked_transport_pair() -> tuple[LinkedMemoryTransport, LinkedMemoryTransport]: + left = LinkedMemoryTransport() + right = LinkedMemoryTransport() + left.partner = right + right.partner = left + return left, right class PeerRuntimeTest(unittest.IsolatedAsyncioTestCase): @@ -332,6 +349,52 @@ class PeerRuntimeTest(unittest.IsolatedAsyncioTestCase): await self.left.stop() + async def test_unexpected_transport_close_fails_pending_invoke(self) -> None: + left, right = make_linked_transport_pair() + started = asyncio.Event() + + async def hanging_invoke(_message, _token): + started.set() + await asyncio.Future() + + core = Peer( + transport=left, + peer_info=PeerInfo(name="core", role="core", version="v4"), + ) + core.set_initialize_handler( + lambda _message: asyncio.sleep( + 0, + result=InitializeOutput( + peer=PeerInfo(name="core", role="core", version="v4"), + capabilities=[], + metadata={}, + ), + ) + ) + core.set_invoke_handler(hanging_invoke) + plugin = Peer( + transport=right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + + await core.start() + await plugin.start() + await plugin.initialize([]) + + task = asyncio.create_task( + plugin.invoke("llm.chat", {"prompt": "close-me"}, request_id="req-close") + ) + await started.wait() + await left.stop() + + with self.assertRaises(AstrBotError) as raised: + await task + self.assertEqual(raised.exception.code, "network_error") + self.assertTrue(raised.exception.retryable) + + await asyncio.wait_for(plugin.wait_closed(), timeout=1.0) + await asyncio.wait_for(core.wait_closed(), timeout=1.0) + class CapabilityRouterContractTest(unittest.TestCase): def test_capability_names_must_match_namespace_method_format(self) -> None: diff --git a/tests_v4/test_runtime.py b/tests_v4/test_runtime.py index 5c6241b3c..a69fdd17d 100644 --- a/tests_v4/test_runtime.py +++ b/tests_v4/test_runtime.py @@ -126,7 +126,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as temp_dir: plugins_root = Path(temp_dir) / "plugins" plugin_root = plugins_root / "compat_plugin" - shutil.copytree(Path.cwd() / "test_plugin", plugin_root) + shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root) runtime = SupervisorRuntime( transport=self.right,