feat: 更新文档和测试,增强旧版插件兼容性及能力装饰器的命名约束

This commit is contained in:
whatevertogo
2026-03-13 06:45:49 +08:00
parent b0f13a00a6
commit 3ba9b24522
7 changed files with 108 additions and 3 deletions

View File

@@ -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.
# 开发命令

View File

@@ -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.
# 开发命令

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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")

View File

@@ -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:

View File

@@ -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,