Files
AstrBot/tests_v4/test_peer.py

549 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import unittest
from astrbot_sdk._invocation_context import caller_plugin_scope
from astrbot_sdk.context import CancelToken
from astrbot_sdk.errors import AstrBotError
from astrbot_sdk.protocol.descriptors import CapabilityDescriptor
from astrbot_sdk.protocol.messages import (
EventMessage,
InitializeOutput,
PeerInfo,
ResultMessage,
)
from astrbot_sdk.runtime.capability_router import CapabilityRouter, StreamExecution
from astrbot_sdk.runtime.peer import Peer
from astrbot_sdk.runtime.transport import (
WebSocketClientTransport,
WebSocketServerTransport,
)
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):
async def asyncSetUp(self) -> None:
self.left, self.right = make_transport_pair()
async def test_initialize_and_call_builtin_capabilities(self) -> None:
router = CapabilityRouter()
core = Peer(
transport=self.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=router.descriptors(),
metadata={},
),
)
)
core.set_invoke_handler(
lambda message, token: router.execute(
message.capability,
message.input,
stream=message.stream,
cancel_token=token,
request_id=message.id,
)
)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await core.start()
await plugin.start()
await plugin.initialize([])
result = await plugin.invoke("llm.chat", {"prompt": "hello"})
self.assertEqual(result["text"], "Echo: hello")
stream = await plugin.invoke_stream("llm.stream_chat", {"prompt": "hi"})
chunks = [event.data["text"] async for event in stream]
self.assertEqual("".join(chunks), "Echo: hi")
await plugin.stop()
await core.stop()
async def test_initialize_carries_remote_provided_capabilities(self) -> None:
provided = CapabilityDescriptor(
name="demo.echo",
description="Echo text",
input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
output_schema={
"type": "object",
"properties": {"echo": {"type": "string"}},
},
)
async def init_handler(message):
self.assertEqual(
[item.name for item in message.provided_capabilities], ["demo.echo"]
)
return InitializeOutput(
peer=PeerInfo(name="core", role="core", version="v4"),
capabilities=[],
metadata={},
)
core = Peer(
transport=self.left,
peer_info=PeerInfo(name="core", role="core", version="v4"),
)
core.set_initialize_handler(init_handler)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await core.start()
await plugin.start()
await plugin.initialize([], provided_capabilities=[provided])
self.assertEqual(
[item.name for item in core.remote_provided_capabilities],
["demo.echo"],
)
await plugin.stop()
await core.stop()
async def test_wait_until_remote_initialized_after_initialize_returns(self) -> None:
core = Peer(
transport=self.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={},
),
)
)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await core.start()
await plugin.start()
await plugin.initialize([])
await plugin.wait_until_remote_initialized(timeout=0.1)
await plugin.stop()
await core.stop()
async def test_invoke_transports_runtime_caller_plugin_id(self) -> None:
captured: list[str | None] = []
async def invoke_handler(message, _token):
captured.append(message.caller_plugin_id)
return {"ok": True}
core = Peer(
transport=self.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(invoke_handler)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await core.start()
await plugin.start()
await plugin.initialize([])
with caller_plugin_scope("demo_plugin"):
result = await plugin.invoke("llm.chat", {"prompt": "hello"})
self.assertEqual(result, {"ok": True})
self.assertEqual(captured, ["demo_plugin"])
await plugin.stop()
await core.stop()
async def test_stream_false_receiving_event_is_protocol_error(self) -> None:
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await self.left.start()
await plugin.start()
task = asyncio.create_task(
plugin.invoke("llm.chat", {"prompt": "bad"}, request_id="req-1")
)
await asyncio.sleep(0)
await self.left.send(
EventMessage(id="req-1", phase="started").model_dump_json()
)
with self.assertRaises(AstrBotError) as raised:
await task
self.assertEqual(raised.exception.code, "protocol_error")
await plugin.stop()
await self.left.stop()
async def test_stream_true_receiving_result_is_protocol_error(self) -> None:
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await self.left.start()
await plugin.start()
stream = await plugin.invoke_stream(
"llm.stream_chat", {"prompt": "bad"}, request_id="stream-1"
)
await self.left.send(
ResultMessage(id="stream-1", success=True, output={}).model_dump_json()
)
with self.assertRaises(AstrBotError) as raised:
async for _ in stream:
pass
self.assertEqual(raised.exception.code, "protocol_error")
await plugin.stop()
await self.left.stop()
async def test_invalid_inbound_message_fails_pending_calls(self) -> None:
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await self.left.start()
await plugin.start()
task = asyncio.create_task(
plugin.invoke("llm.chat", {"prompt": "bad"}, request_id="req-invalid")
)
await asyncio.sleep(0)
with self.assertRaises(AstrBotError) as raised_send:
await self.left.send("[]")
self.assertEqual(raised_send.exception.code, "protocol_error")
with self.assertRaises(AstrBotError) as raised_task:
await task
self.assertEqual(raised_task.exception.code, "protocol_error")
await asyncio.wait_for(plugin.wait_closed(), timeout=1.0)
await self.left.stop()
async def test_cancel_waits_for_failed_terminal_event(self) -> None:
descriptor = CapabilityDescriptor(
name="slow.stream",
description="slow stream",
input_schema={"type": "object", "properties": {}, "required": []},
output_schema={
"type": "object",
"properties": {"count": {"type": "number"}},
"required": ["count"],
},
supports_stream=True,
cancelable=True,
)
async def init_handler(_message):
return InitializeOutput(
peer=PeerInfo(name="core", role="core", version="v4"),
capabilities=[descriptor],
metadata={},
)
async def invoke_handler(message, token: CancelToken):
async def iterator():
while True:
token.raise_if_cancelled()
await asyncio.sleep(0.01)
yield {"text": "x"}
return StreamExecution(
iterator=iterator(),
finalize=lambda chunks: {"count": len(chunks)},
)
core = Peer(
transport=self.left,
peer_info=PeerInfo(name="core", role="core", version="v4"),
)
core.set_initialize_handler(init_handler)
core.set_invoke_handler(invoke_handler)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await core.start()
await plugin.start()
await plugin.initialize([])
stream = await plugin.invoke_stream("slow.stream", {}, request_id="cancel-1")
first = await anext(stream)
self.assertEqual(first.data["text"], "x")
await plugin.cancel("cancel-1")
with self.assertRaises(AstrBotError) as raised:
await anext(stream)
self.assertEqual(raised.exception.code, "cancelled")
await plugin.stop()
await core.stop()
async def test_websocket_transport_smoke(self) -> None:
router = CapabilityRouter()
server_transport = WebSocketServerTransport(port=0)
core = Peer(
transport=server_transport,
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=router.descriptors(),
metadata={},
),
)
)
core.set_invoke_handler(
lambda message, token: router.execute(
message.capability,
message.input,
stream=message.stream,
cancel_token=token,
request_id=message.id,
)
)
await core.start()
client_transport = WebSocketClientTransport(url=server_transport.url)
plugin = Peer(
transport=client_transport,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await plugin.start()
await plugin.initialize([])
result = await plugin.invoke("llm.chat", {"prompt": "ws"})
self.assertEqual(result["text"], "Echo: ws")
await plugin.stop()
await core.stop()
async def test_initialize_failure_closes_receiver_connection(self) -> None:
core = Peer(
transport=self.left,
peer_info=PeerInfo(name="core", role="core", version="v4"),
protocol_version="1.0",
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
protocol_version="2.0",
supported_protocol_versions=["1.0", "2.0"],
)
await core.start()
await plugin.start()
with self.assertRaises(AstrBotError) as raised:
await plugin.initialize([])
self.assertEqual(raised.exception.code, "protocol_version_mismatch")
await asyncio.wait_for(core.wait_closed(), timeout=1.0)
await asyncio.wait_for(plugin.wait_closed(), timeout=1.0)
self.assertTrue(core._closed)
self.assertTrue(plugin._closed)
async def test_initialize_negotiates_lower_minor_protocol_version(self) -> None:
core = Peer(
transport=self.left,
peer_info=PeerInfo(name="core", role="core", version="v4"),
protocol_version="1.0",
supported_protocol_versions=["1.0"],
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
protocol_version="1.1",
supported_protocol_versions=["1.0", "1.1"],
)
await core.start()
await plugin.start()
output = await plugin.initialize([])
self.assertEqual(output.protocol_version, "1.0")
self.assertEqual(plugin.negotiated_protocol_version, "1.0")
self.assertEqual(core.negotiated_protocol_version, "1.0")
self.assertEqual(
core.remote_metadata["supported_protocol_versions"], ["1.1", "1.0"]
)
self.assertEqual(plugin.remote_metadata["negotiated_protocol_version"], "1.0")
await plugin.stop()
await core.stop()
async def test_wait_until_remote_initialized_raises_if_connection_closes_first(
self,
) -> None:
plugin = Peer(
transport=self.right,
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
)
await self.left.start()
await plugin.start()
await plugin.stop()
with self.assertRaises(AstrBotError) as raised:
await plugin.wait_until_remote_initialized(timeout=None)
self.assertEqual(raised.exception.code, "protocol_error")
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:
router = CapabilityRouter()
for name in ("llm", "llm.chat.extra", "LLM.chat", "llm.Chat"):
with self.assertRaises(ValueError) as raised:
router.register(
CapabilityDescriptor(
name=name,
description="invalid",
)
)
self.assertIn(name, str(raised.exception))
def test_reserved_capability_namespaces_are_rejected_for_exposed_registrations(
self,
) -> None:
router = CapabilityRouter()
for name in ("handler.demo", "system.health", "internal.trace"):
with self.assertRaises(ValueError) as raised:
router.register(
CapabilityDescriptor(
name=name,
description="reserved",
)
)
self.assertIn(name, str(raised.exception))
def test_reserved_capability_namespaces_remain_available_for_hidden_internal_registrations(
self,
) -> None:
router = CapabilityRouter()
router.register(
CapabilityDescriptor(
name="system.health",
description="internal only",
),
exposed=False,
)
self.assertNotIn("system.health", [item.name for item in router.descriptors()])