Expand new and legacy sample plugin coverage

This commit is contained in:
whatevertogo
2026-03-13 07:40:42 +08:00
parent 0064716f73
commit da2c252b55
5 changed files with 610 additions and 79 deletions

View File

@@ -1,20 +1,32 @@
"""V4 sample plugin used by integration tests."""
"""V4 sample plugin used by integration tests.
This fixture intentionally exercises the currently supported public v4 API:
- top-level decorators: on_command/on_message/on_event/on_schedule/require_admin
- Context clients: llm, memory, db, platform
- MessageEvent helpers: reply/plain_result/target/to_payload
- plugin-provided capabilities: normal + stream
"""
from __future__ import annotations
import asyncio
from astrbot_sdk import (
Context,
MessageEvent,
Star,
on_command,
on_event,
on_message,
on_schedule,
provide_capability,
require_admin,
)
from astrbot_sdk.context import CancelToken
class HelloPlugin(Star):
"""Small but representative v4 plugin fixture."""
"""Representative v4 plugin fixture."""
@on_command("hello", aliases=["hi"], description="发送问候消息")
async def hello(self, event: MessageEvent, ctx: Context) -> None:
@@ -26,22 +38,50 @@ class HelloPlugin(Star):
chunks.append(chunk)
await event.reply("".join(chunks))
@on_command("remember", description="保存一条记忆并回读")
async def remember(self, event: MessageEvent, ctx: Context) -> None:
@on_command("raw", description="调用 llm.chat_raw 并返回结构化信息")
async def raw(self, event: MessageEvent, ctx: Context):
response = await ctx.llm.chat_raw(
event.text,
system="be concise",
history=[{"role": "user", "content": "history"}],
)
payload = event.to_payload()
ctx.logger.info("raw handler for {}", payload.get("session_id"))
return event.plain_result(
f"raw={response.text}|finish={response.finish_reason}|"
f"cancelled={ctx.cancel_token.cancelled}"
)
@on_command("remember", description="覆盖 memory/db 全量基本操作")
async def remember(self, event: MessageEvent, ctx: Context):
await ctx.memory.save(
"demo:last_message",
{"user_id": event.user_id or "", "text": event.text},
source="fixture",
)
remembered = await ctx.memory.get("demo:last_message") or {}
searched = await ctx.memory.search("fixture")
await ctx.memory.delete("demo:last_message")
await ctx.db.set("demo:last_session", event.session_id)
session_value = await ctx.db.get("demo:last_session")
keys = await ctx.db.list("demo:")
await event.reply(
f"Memory saved for {remembered.get('user_id', 'unknown')} with {len(keys)} keys"
await ctx.db.delete("demo:last_session")
return event.plain_result(
f"remembered={remembered.get('user_id', 'unknown')}|"
f"searched={len(searched)}|session={session_value}|keys={len(keys)}"
)
@on_message(regex=r"^ping$")
async def ping(self, event: MessageEvent) -> None:
await event.reply("pong")
@on_command("platforms", description="覆盖 platform 相关 API")
async def platforms(self, event: MessageEvent, ctx: Context) -> None:
target = event.target or event.session_id
members = await ctx.platform.get_members(target)
await ctx.platform.send_image(target, "https://example.com/demo.png")
await ctx.platform.send(
target,
f"members={len(members)} first={members[0]['user_id'] if members else 'none'}",
)
@on_command("announce", description="发送一条富消息链")
async def announce(self, event: MessageEvent, ctx: Context) -> None:
@@ -53,6 +93,23 @@ class HelloPlugin(Star):
],
)
@require_admin
@on_command("secure", description="测试 require_admin")
async def secure(self, event: MessageEvent):
return event.plain_result(f"secure:{event.user_id or 'unknown'}")
@on_message(regex=r"^ping$", keywords=["ping"], platforms=["test"])
async def ping(self, event: MessageEvent):
return event.plain_result("pong")
@on_event("group_join")
async def on_group_join(self, event: MessageEvent, ctx: Context) -> None:
ctx.logger.info("event handler observed {}", event.text)
@on_schedule(interval_seconds=60)
async def heartbeat(self, ctx: Context) -> None:
await ctx.db.set("demo:last_schedule", {"status": "ok"})
@provide_capability(
"demo.echo",
description="回显输入文本",
@@ -77,7 +134,46 @@ class HelloPlugin(Star):
cancel_token: CancelToken,
) -> dict[str, str]:
cancel_token.raise_if_cancelled()
await ctx.db.set(
"demo:capability_echo",
{"text": str(payload.get("text", ""))},
)
stored = await ctx.db.get("demo:capability_echo") or {}
return {
"echo": str(payload.get("text", "")),
"echo": str(stored.get("text", "")),
"plugin_id": ctx.plugin_id,
}
@provide_capability(
"demo.stream",
description="流式回显输入文本",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object"},
}
},
"required": ["items"],
},
supports_stream=True,
cancelable=True,
)
async def stream_capability(
self,
payload: dict[str, object],
ctx: Context,
cancel_token: CancelToken,
):
text = str(payload.get("text", ""))
await ctx.db.set("demo:last_stream", {"text": text})
for char in text:
cancel_token.raise_if_cancelled()
await asyncio.sleep(0)
yield {"text": char}

View File

@@ -1,103 +1,233 @@
"""旧版插件兼容测试 - 使用 astrbot_sdk 旧版 API
"""旧版插件兼容测试夹具
测试覆盖
- CommandComponent 继承
- filter.command 装饰器
- LegacyContext 功能 (conversation_manager, send_message, db)
- AstrMessageEvent 和 MessageChain
- 消息组件 (Plain, At, Image)
这个样例故意覆盖当前仍被兼容层支持的旧 API
- CommandComponent / AstrMessageEvent / MessageChain
- filter.command / regex / permission / permission_type /
event_message_type / platform_adapter_type
- LegacyContext 的 conversation_manager / llm_generate / tool_loop_agent /
send_message / put|get|delete_kv_data / call_context_function
- 消息组件及其旧字段别名/工厂方法
"""
from __future__ import annotations
from astrbot_sdk import provide_capability
from astrbot_sdk.api.components.command import CommandComponent
from astrbot_sdk.api.event import AstrMessageEvent, filter
from astrbot_sdk.api.event.filter import EventMessageType, PlatformAdapterType
from astrbot_sdk.api.star.context import Context
from astrbot_sdk.api.event.filter import (
ADMIN,
EventMessageType,
PermissionType,
PlatformAdapterType,
)
from astrbot_sdk.api.message import MessageChain
from astrbot_sdk.api.message_components import Plain, At, Image
from astrbot_sdk.api.message_components import (
At,
AtAll,
Face,
File,
Image,
Node,
Plain,
Record,
Reply,
Video,
)
from astrbot_sdk.api.star.context import Context
from loguru import logger
class CompatHelper:
__compat_component_name__ = "CompatHelper"
async def shout(self, text: str) -> str:
return text.upper()
class HelloCommand(CommandComponent):
"""测试旧版 CommandComponent 兼容性。"""
def __init__(self, context: Context):
self.context = context
self.context._register_component(CompatHelper())
@filter.command("hello")
@filter.command("hello", alias={"hi"}, priority=10, desc="问候命令")
async def hello(self, event: AstrMessageEvent):
"""基本命令测试。"""
ret = await self.context.conversation_manager.new_conversation("hello")
logger.info(f"New conversation created: {ret}")
logger.info("New conversation created: {}", ret)
yield event.plain_result(f"Hello, Astrbot! Created conversation ID: {ret}")
@filter.command("echo")
async def echo(self, event: AstrMessageEvent):
"""测试消息获取。"""
"""测试消息获取和 extra 状态"""
text = event.get_message_str()
yield event.plain_result(f"Echo: {text}")
event.set_extra("last_echo", text)
extra = event.get_extra("last_echo")
event.clear_extra()
group = await event.get_group()
yield event.plain_result(
f"Echo: {text}|sender={event.get_sender_id()}|sender_name={event.get_sender_name()}|"
f"platform={event.get_platform_name()}:{event.get_platform_id()}|"
f"type={event.get_message_type().value}|messages={len(event.get_messages())}|"
f"self={event.get_self_id()}|private={event.is_private_chat()}|"
f"wake={event.is_wake_up()}|group={group is not None}|extra={extra}"
)
@filter.command("chain")
async def test_chain(self, event: AstrMessageEvent):
"""测试 MessageChain 构建发送。"""
# 构建消息链
"""测试 MessageChain 构建发送与 react"""
chain = (
MessageChain()
.message("Hello! ")
.message("Hello")
.message(" ")
.at("user", "12345")
.at_all()
.message(" check this image: ")
.url_image("https://example.com/test.png")
.file_image("C:/tmp/test.png")
.base64_image("base64://fixture")
.use_t2i(True)
.squash_plain()
)
# 测试 to_payload 和 is_plain_text_only
payload = chain.to_payload()
is_plain = chain.is_plain_text_only()
logger.info(f"Chain payload: {payload}, is_plain_text_only: {is_plain}")
yield event.plain_result(f"Chain sent with {len(payload)} components")
await event.send(chain)
await event.react(":thumbsup:")
yield event.plain_result(
f"Chain sent with {len(chain.to_payload())} components and t2i={chain.use_t2i_}"
)
@filter.command("db")
async def test_db(self, event: AstrMessageEvent):
"""测试 KV 数据库操作。"""
# 写入数据
await self.context.put_kv_data("test_key", {"value": "test_data"})
# 读取数据
data = await self.context.get_kv_data("test_key")
logger.info(f"Got data from db: {data}")
await self.context.delete_kv_data("test_key")
deleted = await self.context.get_kv_data("test_key", "missing")
logger.info("Got data from db: {}", data)
yield event.plain_result(f"DB test: stored={data} deleted={deleted}")
yield event.plain_result(f"DB test: stored and retrieved {data}")
@filter.command("conversation")
async def test_conversation(self, event: AstrMessageEvent):
"""测试会话管理器和 call_context_function。"""
umo = f"platform:{event.get_session_id()}"
cid = await self.context.conversation_manager.new_conversation(
unified_msg_origin=umo,
title="Compat Chat",
persona_id="assistant",
)
await self.context.conversation_manager.add_message_pair(cid, "hello", "world")
await self.context.conversation_manager.update_conversation(
unified_msg_origin=umo,
conversation_id=cid,
title="Compat Chat Updated",
)
await self.context.conversation_manager.update_conversation_title(umo, "Compat")
await self.context.conversation_manager.update_conversation_persona_id(
umo,
"compat-persona",
)
current_id = await self.context.conversation_manager.get_curr_conversation_id(
umo
)
conv = await self.context.conversation_manager.get_conversation(umo, cid)
all_conversations = await self.context.conversation_manager.get_conversations(
umo
)
helper_result = await self.context.call_context_function(
"CompatHelper.shout",
{"text": "compat"},
)
await self.context.conversation_manager.switch_conversation(umo, cid)
await self.context.conversation_manager.delete_conversation(umo, cid)
yield event.plain_result(
f"conversation={current_id}|messages={len((conv or {}).get('content', []))}|"
f"all={len(all_conversations)}|helper={helper_result['data']}"
)
@filter.command("ai")
async def test_ai(self, event: AstrMessageEvent):
"""测试旧版 AI compat 入口。"""
llm_resp = await self.context.llm_generate(
chat_provider_id="provider-demo",
prompt="legacy hello",
contexts=[{"role": "user", "content": "hi"}],
)
agent_resp = await self.context.tool_loop_agent(
chat_provider_id="provider-demo",
prompt="legacy hello",
tools=[{"name": "search"}],
max_steps=3,
)
yield event.plain_result(f"LLM:{llm_resp.text}|AGENT:{agent_resp.text}")
@filter.command("sendmsg")
async def test_send_message(self, event: AstrMessageEvent):
"""测试 LegacyContext.send_message。"""
chain = (
MessageChain()
.message("compat send ")
.at("legacy-user", "10001")
.url_image("https://example.com/send.png")
)
await self.context.send_message(event.get_session_id(), chain)
yield event.plain_result("send_message invoked")
@filter.command("components")
async def test_components(self, event: AstrMessageEvent):
"""测试消息组件。"""
# 测试 Plain
plain = Plain(text="Hello")
"""测试消息组件和 chain_result/image_result"""
components = [
Plain(text="Hello"),
At(qq="123", name="legacy_user"),
AtAll(),
Image.fromURL("https://example.com/img.png"),
Image.fromFileSystem("C:/tmp/local.png"),
Record.fromFileSystem("C:/tmp/sound.wav"),
Video.fromURL("https://example.com/video.mp4"),
File(name="demo.txt", file="https://example.com/demo.txt"),
Reply(id="reply-1"),
Node(uin="10001", name="node_user", content=[Plain(text="node")]),
Face(id=1),
]
component_dicts = [component.to_dict() for component in components]
logger.info("Components: {}", component_dicts)
yield event.chain_result(components)
yield event.image_result("https://example.com/components.png")
# 测试 At
at = At(user_id="123", user_name="test_user")
# 测试 Image
image = Image.fromURL("https://example.com/img.png")
# 测试 to_dict
plain_dict = plain.to_dict()
at_dict = at.to_dict()
image_dict = image.to_dict()
logger.info(f"Plain: {plain_dict}, At: {at_dict}, Image: {image_dict}")
yield event.plain_result("Components created: Plain, At, Image")
@filter.command("state")
async def test_event_state(self, event: AstrMessageEvent):
"""测试事件状态方法。"""
result = event.make_result().message("state-ok")
event.set_result(result)
before_stop = event.is_stopped()
event.stop_event()
after_stop = event.is_stopped()
event.continue_event()
after_continue = event.is_stopped()
event.should_call_llm(True)
stored = event.get_result()
event.clear_result()
yield event.plain_result(
f"before={before_stop}|after_stop={after_stop}|after_continue={after_continue}|"
f"call_llm={event.call_llm}|stored={stored is not None}"
)
@filter.regex(r"^ping.*")
async def ping_regex(self, event: AstrMessageEvent):
"""测试正则匹配。"""
yield event.plain_result("Pong from regex!")
@filter.permission("admin")
@filter.permission(ADMIN)
@filter.command("admin")
async def admin_only(self, event: AstrMessageEvent):
"""测试权限过滤 (应该被跳过,因为没有 require_admin)。"""
yield event.plain_result("Admin command executed")
"""测试 permission(ADMIN)。"""
yield event.plain_result(f"Admin command executed by {event.is_admin()}")
@filter.permission_type(PermissionType.ADMIN)
@filter.command("admin_type")
async def admin_type_only(self, event: AstrMessageEvent):
"""测试 permission_type(PermissionType.ADMIN)。"""
yield event.plain_result(f"Admin type command executed by {event.is_admin()}")
@filter.event_message_type(EventMessageType.GROUP_MESSAGE)
async def group_only(self, event: AstrMessageEvent):
@@ -108,3 +238,30 @@ class HelloCommand(CommandComponent):
async def cqhttp_only(self, event: AstrMessageEvent):
"""测试平台过滤。"""
yield event.plain_result("CQHttp platform detected")
@provide_capability(
"compat.echo",
description="使用 legacy Context 回显输入文本",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"echo": {"type": "string"},
"plugin_id": {"type": "string"},
},
"required": ["echo", "plugin_id"],
},
)
async def echo_capability(self, payload: dict):
"""测试旧版插件 capability 能走 LegacyContext。"""
text = str(payload.get("text", ""))
await self.context.put_kv_data("compat_capability", {"text": text})
stored = await self.context.get_kv_data("compat_capability", {})
return {
"echo": str(stored.get("text", "")),
"plugin_id": self.context.plugin_id,
}

View File

@@ -496,10 +496,35 @@ class TestRealTestPlugin:
assert loaded.plugin.name == "astrbot_plugin_helloworld"
assert len(loaded.instances) == 1
assert len(loaded.handlers) >= 1
assert [item.descriptor.name for item in loaded.capabilities] == [
"compat.echo"
]
# 验证处理器
handler_ids = [h.descriptor.id for h in loaded.handlers]
assert any("hello" in hid for hid in handler_ids)
command_names = {
handler.descriptor.trigger.command
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, CommandTrigger)
}
assert {
"admin",
"admin_type",
"ai",
"chain",
"components",
"conversation",
"db",
"echo",
"hello",
"sendmsg",
"state",
} <= command_names
regex_triggers = [
handler.descriptor.trigger
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, MessageTrigger)
]
assert any(trigger.regex == r"^ping.*" for trigger in regex_triggers)
# 验证实例类型
instance = loaded.instances[0]

View File

@@ -7,7 +7,12 @@ from pathlib import Path
import pytest
from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger
from astrbot_sdk.protocol.descriptors import (
CommandTrigger,
EventTrigger,
MessageTrigger,
ScheduleTrigger,
)
from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec
@@ -49,22 +54,59 @@ class TestRealNewTestPlugin:
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, MessageTrigger)
]
event_triggers = [
handler.descriptor.trigger
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, EventTrigger)
]
schedule_triggers = [
handler.descriptor.trigger
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, ScheduleTrigger)
]
handler_map = {
handler.descriptor.id: handler.descriptor for handler in loaded.handlers
}
assert {trigger.command for trigger in command_triggers} == {
"announce",
"hello",
"platforms",
"raw",
"remember",
"secure",
}
hello_trigger = next(
trigger for trigger in command_triggers if trigger.command == "hello"
)
assert "hi" in hello_trigger.aliases
secure_descriptor = next(
descriptor
for descriptor in handler_map.values()
if getattr(descriptor.trigger, "command", None) == "secure"
)
assert secure_descriptor.permissions.require_admin is True
assert len(message_triggers) == 1
assert message_triggers[0].regex == r"^ping$"
assert message_triggers[0].keywords == ["ping"]
assert message_triggers[0].platforms == ["test"]
assert len(event_triggers) == 1
assert event_triggers[0].event_type == "group_join"
assert len(schedule_triggers) == 1
assert schedule_triggers[0].interval_seconds == 60
capability_names = [item.descriptor.name for item in loaded.capabilities]
assert capability_names == ["demo.echo"]
assert capability_names == ["demo.echo", "demo.stream"]
stream_descriptor = next(
item.descriptor
for item in loaded.capabilities
if item.descriptor.name == "demo.stream"
)
assert stream_descriptor.supports_stream is True
assert stream_descriptor.cancelable is True
finally:
for path in paths_to_add:
if path in sys.path:

View File

@@ -39,6 +39,13 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self) -> None:
await self.core.stop()
def _find_handler_id(self, command_name: str) -> str:
return next(
handler.id
for handler in self.core.remote_handlers
if getattr(handler.trigger, "command", None) == command_name
)
async def test_supervisor_runs_v4_plugin_over_stdio_worker(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
@@ -53,11 +60,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
handler_id = next(
handler.id
for handler in self.core.remote_handlers
if getattr(handler.trigger, "command", None) == "hello"
)
handler_id = self._find_handler_id("hello")
await self.core.invoke(
"handler.invoke",
@@ -79,6 +82,77 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
finally:
await runtime.stop()
async def test_supervisor_runs_v4_plugin_client_commands(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "v4_plugin"
shutil.copytree(sample_plugin_dir("new"), plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
plugins_dir=plugins_root,
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
for request_id, command_name in (
("call-v4-raw", "raw"),
("call-v4-remember", "remember"),
("call-v4-platforms", "platforms"),
):
await self.core.invoke(
"handler.invoke",
{
"handler_id": self._find_handler_id(command_name),
"event": {
"text": command_name,
"session_id": "session-v4-clients",
"user_id": "user-1",
"platform": "test",
},
},
request_id=request_id,
)
texts = [
item.get("text")
for item in runtime.capability_router.sent_messages
if "text" in item
]
self.assertTrue(
any(text.startswith("raw=Echo: raw|finish=stop|") for text in texts)
)
self.assertTrue(
any(
text.startswith(
"remembered=user-1|searched=1|session=session-v4-clients|keys=1"
)
for text in texts
)
)
image_message = next(
item
for item in runtime.capability_router.sent_messages
if item.get("image_url") == "https://example.com/demo.png"
)
self.assertEqual(image_message["session"], "session-v4-clients")
self.assertEqual(
image_message["target"]["conversation_id"],
"session-v4-clients",
)
self.assertTrue(
any(
item.get("text", "").startswith(
"members=2 first=session-v4-clients:member-1"
)
for item in runtime.capability_router.sent_messages
)
)
finally:
await runtime.stop()
async def test_supervisor_exposes_real_v4_plugin_capability(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
@@ -99,6 +173,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
for descriptor in self.core.remote_provided_capabilities
}
self.assertIn("demo.echo", capability_names)
self.assertIn("demo.stream", capability_names)
result = await self.core.invoke(
"demo.echo",
@@ -129,11 +204,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
handler_id = next(
handler.id
for handler in self.core.remote_handlers
if getattr(handler.trigger, "command", None) == "announce"
)
handler_id = self._find_handler_id("announce")
await self.core.invoke(
"handler.invoke",
@@ -162,6 +233,31 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
finally:
await runtime.stop()
async def test_supervisor_exposes_real_v4_stream_capability(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "v4_plugin"
shutil.copytree(sample_plugin_dir("new"), plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
plugins_dir=plugins_root,
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
stream = await self.core.invoke_stream(
"demo.stream",
{"text": "abc"},
request_id="call-v4-stream-capability",
)
chunks = [event.data["text"] async for event in stream]
self.assertEqual(chunks, ["a", "b", "c"])
finally:
await runtime.stop()
async def test_supervisor_runs_compat_plugin(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
@@ -176,11 +272,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
handler_id = next(
handler.id
for handler in self.core.remote_handlers
if getattr(handler.trigger, "command", None) == "hello"
)
handler_id = self._find_handler_id("hello")
await self.core.invoke(
"handler.invoke",
@@ -202,3 +294,122 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("Created conversation ID", texts[0])
finally:
await runtime.stop()
async def test_supervisor_runs_compat_plugin_extended_api_commands(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "compat_plugin"
shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
plugins_dir=plugins_root,
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
for request_id, command_name in (
("call-compat-ai", "ai"),
("call-compat-conversation", "conversation"),
("call-compat-sendmsg", "sendmsg"),
("call-compat-chain", "chain"),
("call-compat-components", "components"),
):
await self.core.invoke(
"handler.invoke",
{
"handler_id": self._find_handler_id(command_name),
"event": {
"text": command_name,
"session_id": "session-compat-extended",
"user_id": "user-1",
"platform": "test",
},
},
request_id=request_id,
)
texts = [
item.get("text")
for item in runtime.capability_router.sent_messages
if "text" in item
]
self.assertTrue(
any(
text.startswith(
"LLM:Echo: legacy hello|AGENT:Echo: legacy hello"
)
for text in texts
)
)
self.assertTrue(
any(
text.startswith("conversation=") and "|helper=COMPAT" in text
for text in texts
)
)
self.assertTrue(any(text == "send_message invoked" for text in texts))
chain_messages = [
item
for item in runtime.capability_router.sent_messages
if "chain" in item
]
self.assertTrue(
any(
any(
component.get("type") == "At"
and component.get("user_id") == "all"
for component in item["chain"]
)
for item in chain_messages
)
)
self.assertTrue(
any(
any(
component.get("type") == "Node"
for component in item["chain"]
)
for item in chain_messages
)
)
finally:
await runtime.stop()
async def test_supervisor_exposes_compat_plugin_capability(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "compat_plugin"
shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
plugins_dir=plugins_root,
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
capability_names = {
descriptor.name
for descriptor in self.core.remote_provided_capabilities
}
self.assertIn("compat.echo", capability_names)
result = await self.core.invoke(
"compat.echo",
{"text": "legacy-capability"},
request_id="call-compat-capability",
)
self.assertEqual(
result,
{
"echo": "legacy-capability",
"plugin_id": "astrbot_plugin_helloworld",
},
)
finally:
await runtime.stop()