diff --git a/AGENTS.md b/AGENTS.md index 1db68c203..53948255a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - 2026-03-13: `WorkerSession` cannot assume the caller-provided `repo_root` contains `src-new/astrbot_sdk`. Tests and external bootstraps may pass a temporary repo root while still expecting the in-tree SDK package to launch worker subprocesses via `python -m astrbot_sdk`. Resolve the SDK source directory from the real package location when the supplied root does not contain it. - 2026-03-13: `MemoryClient.get()` is part of the supported v4 client surface and must stay in sync with `CapabilityRouter` built-ins. The client method existed while the router forgot to register `memory.get`, which caused a real runtime gap hidden by API shape alone. - 2026-03-13: When checking whether a peer has finished remote initialization, avoid `getattr(mock, "remote_peer")` style probes in code that may receive `MagicMock` peers. `MagicMock` fabricates truthy child attributes, so `CapabilityProxy` should read explicit state from `peer.__dict__` or another concrete storage location instead of treating arbitrary attribute access as initialization. +- 2026-03-13: The repository has no legacy `src/astrbot_sdk/protocol` package to migrate file-for-file. `src-new/astrbot_sdk/protocol` is a v4-native protocol layer; compare it against legacy JSON-RPC behavior in `src/astrbot_sdk/runtime/*` and the maintained migration tests, not against a nonexistent old package tree. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index 1db68c203..53948255a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ - 2026-03-13: `WorkerSession` cannot assume the caller-provided `repo_root` contains `src-new/astrbot_sdk`. Tests and external bootstraps may pass a temporary repo root while still expecting the in-tree SDK package to launch worker subprocesses via `python -m astrbot_sdk`. Resolve the SDK source directory from the real package location when the supplied root does not contain it. - 2026-03-13: `MemoryClient.get()` is part of the supported v4 client surface and must stay in sync with `CapabilityRouter` built-ins. The client method existed while the router forgot to register `memory.get`, which caused a real runtime gap hidden by API shape alone. - 2026-03-13: When checking whether a peer has finished remote initialization, avoid `getattr(mock, "remote_peer")` style probes in code that may receive `MagicMock` peers. `MagicMock` fabricates truthy child attributes, so `CapabilityProxy` should read explicit state from `peer.__dict__` or another concrete storage location instead of treating arbitrary attribute access as initialization. +- 2026-03-13: The repository has no legacy `src/astrbot_sdk/protocol` package to migrate file-for-file. `src-new/astrbot_sdk/protocol` is a v4-native protocol layer; compare it against legacy JSON-RPC behavior in `src/astrbot_sdk/runtime/*` and the maintained migration tests, not against a nonexistent old package tree. # 开发命令 diff --git a/src-new/astrbot_sdk/protocol/__init__.py b/src-new/astrbot_sdk/protocol/__init__.py index f0e7c55f6..69c4a7907 100644 --- a/src-new/astrbot_sdk/protocol/__init__.py +++ b/src-new/astrbot_sdk/protocol/__init__.py @@ -1,76 +1,101 @@ -"""协议模块。 +"""AstrBot v4 协议公共入口。 -定义 AstrBot SDK 的消息协议和描述符,用于插件与核心之间的通信。 -所有消息均使用 Pydantic 定义,确保类型安全和序列化一致性。 +这里暴露的是协议层的公共模型和 legacy 适配入口。需要区分两件事: -架构说明: - 旧版: - - 使用标准 JSON-RPC 2.0 协议 - - 消息类型较少: Request, SuccessResponse, ErrorResponse - - 特定请求类型绑定了 AstrMessageEventModel 事件模型 - - 使用 dataclass 和 pydantic 混合定义 +1. v4 原生协议: + `InitializeMessage` / `InvokeMessage` / `ResultMessage` / `EventMessage` +2. legacy JSON-RPC 兼容: + `LegacyAdapter` 及其若干便捷转换函数 - 新版: - - 全新的自描述协议,使用 `type` 字段区分消息类型 - - 更丰富的消息类型: Initialize, Result, Invoke, Event, Cancel - - 强大的描述符系统: HandlerDescriptor, CapabilityDescriptor - - 多种触发器类型: Command, Message, Event, Schedule - - 纯 Pydantic 定义,支持严格验证 - - 提供 LegacyAdapter 实现新旧协议互操作 - -协议消息流程: - 1. Initialize: 握手建立连接,交换能力和处理器信息 - 2. Invoke: 调用远程能力 - 3. Event: 流式事件通知 (started/delta/completed/failed) - 4. Result: 调用结果返回 - 5. Cancel: 取消正在进行的调用 - -与旧版对比: - 旧版 JSON-RPC 消息: - { - "jsonrpc": "2.0", - "id": "xxx", - "method": "call_handler", - "params": {"handler_full_name": "...", "event": {...}} - } - - 新版协议消息: - { - "type": "invoke", - "id": "xxx", - "capability": "handler.invoke", - "input": {"handler_id": "...", "event": {...}} - } - -TODO: (功能完善): - - 添加消息签名验证支持,确保消息来源可信 - - 添加消息压缩支持,减少大数据传输开销 - - 添加批量消息支持 (BatchMessage),提高传输效率 - - 添加消息追踪 ID (trace_id) 支持,便于日志关联 - - CapabilityDescriptor 缺少 rate_limit 限流配置 - - HandlerDescriptor 缺少 timeout 超时配置 - - 缺少心跳消息 (HeartbeatMessage) 支持 - - 缺少健康检查消息 (HealthCheckMessage) 支持 +握手阶段由 `InitializeMessage` 发起,返回值不是另一条 initialize 消息,而是 +`ResultMessage(kind="initialize_result")`,其 `output` 负载可解析为 +`InitializeOutput`。 """ -from .descriptors import CapabilityDescriptor, HandlerDescriptor, Permissions +from .descriptors import ( + CapabilityDescriptor, + CommandTrigger, + EventTrigger, + HandlerDescriptor, + MessageTrigger, + Permissions, + ScheduleTrigger, + Trigger, +) +from .legacy_adapter import ( + LEGACY_ADAPTER_MESSAGE_EVENT, + LEGACY_CONTEXT_CAPABILITY, + LEGACY_HANDSHAKE_METADATA_KEY, + LEGACY_JSONRPC_VERSION, + LEGACY_PLUGIN_KEYS_METADATA_KEY, + LegacyAdapter, + LegacyErrorData, + LegacyErrorResponse, + LegacyMessage, + LegacyRequest, + LegacySuccessResponse, + LegacyToV4Message, + cancel_to_legacy_request, + event_to_legacy_notification, + initialize_to_legacy_handshake_response, + invoke_to_legacy_request, + legacy_message_to_v4, + legacy_request_to_invoke, + legacy_response_to_message, + parse_legacy_message, + result_to_legacy_response, +) from .messages import ( CancelMessage, + ErrorPayload, EventMessage, InitializeMessage, InitializeOutput, InvokeMessage, + PeerInfo, + ProtocolMessage, ResultMessage, + parse_message, ) __all__ = [ "CapabilityDescriptor", + "CommandTrigger", "CancelMessage", + "ErrorPayload", + "EventTrigger", "EventMessage", "HandlerDescriptor", "InitializeMessage", "InitializeOutput", "InvokeMessage", + "LEGACY_ADAPTER_MESSAGE_EVENT", + "LEGACY_CONTEXT_CAPABILITY", + "LEGACY_HANDSHAKE_METADATA_KEY", + "LEGACY_JSONRPC_VERSION", + "LEGACY_PLUGIN_KEYS_METADATA_KEY", + "LegacyAdapter", + "LegacyErrorData", + "LegacyErrorResponse", + "LegacyMessage", + "LegacyRequest", + "LegacySuccessResponse", + "LegacyToV4Message", + "MessageTrigger", + "PeerInfo", "Permissions", + "ProtocolMessage", "ResultMessage", + "ScheduleTrigger", + "Trigger", + "cancel_to_legacy_request", + "event_to_legacy_notification", + "initialize_to_legacy_handshake_response", + "invoke_to_legacy_request", + "legacy_message_to_v4", + "legacy_request_to_invoke", + "legacy_response_to_message", + "parse_legacy_message", + "parse_message", + "result_to_legacy_response", ] diff --git a/src-new/astrbot_sdk/protocol/descriptors.py b/src-new/astrbot_sdk/protocol/descriptors.py index 43994a61c..3fcd5eb1b 100644 --- a/src-new/astrbot_sdk/protocol/descriptors.py +++ b/src-new/astrbot_sdk/protocol/descriptors.py @@ -1,39 +1,8 @@ -"""描述符模块。 +"""v4 协议描述符模型。 -定义处理器和能力的描述符,用于声明式注册和发现。 - -描述符类型概览: - HandlerDescriptor: 处理器描述符,描述一个事件处理函数 - CapabilityDescriptor: 能力描述符,描述一个可调用的远程能力 - Permissions: 权限配置,控制处理器的访问权限 - Trigger: 触发器联合类型,支持多种触发方式 - -触发器类型: - CommandTrigger: 命令触发器,响应特定命令(如 /help) - MessageTrigger: 消息触发器,响应匹配正则或关键词的消息 - EventTrigger: 事件触发器,响应特定类型的事件 - ScheduleTrigger: 定时触发器,按 cron 或间隔时间执行 - -与旧版对比: - 旧版: - - 处理器元信息分散在 handshake 响应中 - - 使用 event_type 整数区分事件类型 - - 缺少声明式的触发器定义 - - 配置通过 extras_configs 字典传递 - - 新版: - - 使用 HandlerDescriptor 统一描述处理器 - - 使用字符串 event_type,更语义化 - - 支持多种触发器类型,声明式定义 - - 使用 Pydantic 模型,类型安全 - -TODO: - - HandlerDescriptor 缺少 timeout 超时配置 - - HandlerDescriptor 缺少 retry 重试配置 - - CapabilityDescriptor 缺少 rate_limit 限流配置 - - ScheduleTrigger 缺错时错过执行的处理策略 - - 缺少 HandlerGroupDescriptor 处理器组描述符 - - 缺少 DependencyDescriptor 依赖声明 +`protocol` 是 v4 新引入的协议层抽象,不对应旧树中的一个同名目录。这里 +定义的是跨进程握手和调度时使用的声明式元数据,而不是运行时的具体处理器/ +能力实现。 """ from __future__ import annotations @@ -85,7 +54,7 @@ class CommandTrigger(_DescriptorBase): class MessageTrigger(_DescriptorBase): - """消息触发器,响应匹配正则或关键词的消息。 + """消息触发器,描述消息类处理器的订阅条件。 与旧版对比: 旧版: 使用 @regex_handler(r"pattern") 或 @message_handler 装饰器 @@ -96,6 +65,10 @@ class MessageTrigger(_DescriptorBase): regex: 正则表达式模式,匹配消息文本 keywords: 关键词列表,消息包含任一关键词即触发 platforms: 目标平台列表,为空表示所有平台 + + Note: + `regex` 和 `keywords` 可以同时为空,此时表示“任意消息均可触发”, + 仅由平台过滤或上层运行时进一步筛选。 """ type: Literal["message"] = "message" @@ -224,3 +197,15 @@ class CapabilityDescriptor(_DescriptorBase): output_schema: dict[str, Any] | None = None supports_stream: bool = False cancelable: bool = False + + +__all__ = [ + "CapabilityDescriptor", + "CommandTrigger", + "EventTrigger", + "HandlerDescriptor", + "MessageTrigger", + "Permissions", + "ScheduleTrigger", + "Trigger", +] diff --git a/src-new/astrbot_sdk/protocol/legacy_adapter.py b/src-new/astrbot_sdk/protocol/legacy_adapter.py index 21ac1bec7..ba05ff1ad 100644 --- a/src-new/astrbot_sdk/protocol/legacy_adapter.py +++ b/src-new/astrbot_sdk/protocol/legacy_adapter.py @@ -1,35 +1,8 @@ -"""旧版协议适配器模块。 +"""legacy JSON-RPC 与 v4 协议之间的适配器。 -提供旧版 JSON-RPC 协议与新版协议之间的双向转换。 -支持旧版插件与新版核心的互操作。 - -主要功能: - - 将旧版 JSON-RPC 请求转换为新版 InvokeMessage - - 将旧版 JSON-RPC 响应转换为新版 ResultMessage - - 将旧版 handshake 转换为新版 InitializeMessage - - 将新版消息转换回旧版格式(用于与旧版核心通信) - -转换映射表: - 旧版 method -> 新版 capability - ------------------------------------------------ - handshake -> InitializeMessage - call_handler -> handler.invoke - call_context_function -> internal.legacy.call_context_function - handler_stream_start -> EventMessage(phase="started") - handler_stream_update -> EventMessage(phase="delta") - handler_stream_end -> EventMessage(phase="completed"/"failed") - cancel -> CancelMessage - -注意事项: - - 旧版 handshake 的 metadata 信息可能丢失部分字段 - - 新版触发器的详细信息在转换时可能丢失 - - 使用 LEGACY_HANDSHAKE_METADATA_KEY 保留原始握手数据 - -TODO: - - 添加旧版消息版本检测和兼容性警告 - - 添加消息转换日志记录,便于调试 - - 支持自定义转换规则扩展 - - 添加转换性能监控 +旧树没有独立的 `protocol` 包;这里做的是“旧 JSON-RPC 运行时语义”到 +“v4 协议模型”的转换。它不是完美双向同构,尤其是 legacy handshake 无法 +保留 v4 触发器的全部细节,因此适配器会保留原始握手载荷供兼容层回退使用。 """ from __future__ import annotations @@ -627,6 +600,8 @@ def parse_legacy_message( payload = payload.decode("utf-8") if isinstance(payload, str): payload = json.loads(payload) + if not isinstance(payload, dict): + raise ValueError("legacy JSON-RPC 消息必须是 JSON object") if "method" in payload: return LegacyRequest.model_validate(payload) if "result" in payload: @@ -642,7 +617,9 @@ def legacy_message_to_v4( return LegacyAdapter().legacy_to_v4(payload) -def legacy_request_to_invoke(payload: dict[str, Any]) -> InvokeMessage: +def legacy_request_to_invoke( + payload: str | bytes | dict[str, Any] | LegacyRequest, +) -> InvokeMessage: message = LegacyAdapter().legacy_request_to_message(payload) if not isinstance(message, InvokeMessage): raise ValueError("legacy request 不能直接映射为 invoke") @@ -650,7 +627,7 @@ def legacy_request_to_invoke(payload: dict[str, Any]) -> InvokeMessage: def legacy_response_to_message( - payload: dict[str, Any], + payload: str | bytes | dict[str, Any] | LegacySuccessResponse, ) -> InitializeMessage | ResultMessage: message = LegacyAdapter().legacy_response_to_message(payload) return message @@ -695,11 +672,14 @@ __all__ = [ "LEGACY_CONTEXT_CAPABILITY", "LEGACY_HANDSHAKE_METADATA_KEY", "LEGACY_PLUGIN_KEYS_METADATA_KEY", + "LEGACY_JSONRPC_VERSION", "LegacyAdapter", "LegacyErrorData", "LegacyErrorResponse", + "LegacyMessage", "LegacyRequest", "LegacySuccessResponse", + "LegacyToV4Message", "cancel_to_legacy_request", "event_to_legacy_notification", "initialize_to_legacy_handshake_response", diff --git a/src-new/astrbot_sdk/protocol/messages.py b/src-new/astrbot_sdk/protocol/messages.py index 96a50dce4..d57f4df4a 100644 --- a/src-new/astrbot_sdk/protocol/messages.py +++ b/src-new/astrbot_sdk/protocol/messages.py @@ -1,47 +1,9 @@ -"""协议消息定义模块。 +"""v4 协议消息模型。 -定义 AstrBot SDK 的核心消息类型,所有消息均继承自 Pydantic BaseModel。 - -消息类型概览: - InitializeMessage: 握手初始化消息,包含 Peer 信息和处理器列表 - ResultMessage: 调用结果消息,包含成功/失败状态和输出数据 - InvokeMessage: 能力调用消息,指定目标能力和输入参数 - EventMessage: 流式事件消息,用于流式调用的状态通知 - CancelMessage: 取消消息,用于取消正在进行的调用 - -消息生命周期: - 握手阶段: - Plugin -> Core: InitializeMessage (注册处理器) - Core -> Plugin: ResultMessage (确认或拒绝) - - 调用阶段: - Plugin -> Core: InvokeMessage (调用能力) - Core -> Plugin: ResultMessage (返回结果) - 或者 (流式): - Core -> Plugin: EventMessage (started -> delta* -> completed/failed) - - 取消阶段: - Plugin -> Core: CancelMessage (取消调用) - -与旧版对比: - 旧版 JSON-RPC: - - 使用 method 字段区分操作类型 - - 使用 jsonrpc: "2.0" 标识协议版本 - - 错误码为整数 (如 -32000) - - 无专门的取消消息类型 - - 新版协议: - - 使用 type 字段区分消息类型 - - 使用 protocol_version 字段标识版本 - - 错误码为字符串 (如 "internal_error") - - 有专门的 CancelMessage 取消消息 - -TODO: - - 添加消息过期时间 (expires_at) 支持 - - 添加消息优先级 (priority) 支持 - - 添加消息重试计数 (retry_count) 支持 - - ErrorPayload 缺少 stack_trace 字段(调试用) - - InitializeMessage 缺少 authentication 认证字段 +这些模型描述的是 `Peer` 与 `Peer` 之间的线协议。握手阶段通过 +`InitializeMessage` 发起,再由 `ResultMessage(kind="initialize_result")` +返回 `InitializeOutput`;能力调用阶段则使用 `InvokeMessage` / `ResultMessage` +或 `EventMessage` 序列。 """ from __future__ import annotations @@ -184,6 +146,19 @@ class ResultMessage(_MessageBase): output: dict[str, Any] = Field(default_factory=dict) error: ErrorPayload | None = None + @model_validator(mode="after") + def validate_result_state(self) -> "ResultMessage": + """约束 success / output / error 的组合状态。""" + if self.success: + if self.error is not None: + raise ValueError("success=true 时 error 必须为空") + return self + if self.error is None: + raise ValueError("success=false 时必须提供 error") + if self.output: + raise ValueError("success=false 时 output 必须为空") + return self + class InvokeMessage(_MessageBase): """调用消息,用于请求执行远程能力。 @@ -309,15 +284,25 @@ ProtocolMessage = ( ) """协议消息联合类型,所有有效消息类型的联合。""" +_PROTOCOL_MESSAGE_MODELS = { + "initialize": InitializeMessage, + "result": ResultMessage, + "invoke": InvokeMessage, + "event": EventMessage, + "cancel": CancelMessage, +} -def parse_message(payload: str | bytes | dict[str, Any]) -> ProtocolMessage: + +def parse_message( + payload: ProtocolMessage | str | bytes | dict[str, Any], +) -> ProtocolMessage: """解析协议消息。 从原始载荷(字符串、字节或字典)解析为对应的 ProtocolMessage 类型。 根据 "type" 字段自动识别消息类型并验证。 Args: - payload: 原始消息载荷,支持 JSON 字符串、字节或字典 + payload: 原始消息载荷,支持已解析模型、JSON 字符串、字节或字典 Returns: 解析后的协议消息对象 @@ -330,19 +315,39 @@ def parse_message(payload: str | bytes | dict[str, Any]) -> ProtocolMessage: >>> isinstance(msg, InvokeMessage) True """ + if isinstance( + payload, + ( + InitializeMessage, + ResultMessage, + InvokeMessage, + EventMessage, + CancelMessage, + ), + ): + return payload if isinstance(payload, bytes): payload = payload.decode("utf-8") if isinstance(payload, str): payload = json.loads(payload) + if not isinstance(payload, dict): + raise ValueError("协议消息必须是 JSON object") message_type = payload.get("type") - if message_type == "initialize": - return InitializeMessage.model_validate(payload) - if message_type == "result": - return ResultMessage.model_validate(payload) - if message_type == "invoke": - return InvokeMessage.model_validate(payload) - if message_type == "event": - return EventMessage.model_validate(payload) - if message_type == "cancel": - return CancelMessage.model_validate(payload) + model = _PROTOCOL_MESSAGE_MODELS.get(str(message_type)) + if model is not None: + return model.model_validate(payload) raise ValueError(f"未知消息类型:{message_type}") + + +__all__ = [ + "CancelMessage", + "ErrorPayload", + "EventMessage", + "InitializeMessage", + "InitializeOutput", + "InvokeMessage", + "PeerInfo", + "ProtocolMessage", + "ResultMessage", + "parse_message", +] diff --git a/tests_v4/test_protocol_legacy_adapter.py b/tests_v4/test_protocol_legacy_adapter.py index f6178230b..031ac89cd 100644 --- a/tests_v4/test_protocol_legacy_adapter.py +++ b/tests_v4/test_protocol_legacy_adapter.py @@ -158,6 +158,11 @@ class TestParseLegacyMessage: parse_legacy_message({"jsonrpc": "2.0", "unknown": "field"}) assert "未知" in str(exc_info.value) + def test_parse_non_mapping_raises(self): + """parse_legacy_message should reject non-object payloads.""" + with pytest.raises(ValueError, match="JSON object"): + parse_legacy_message(["not", "an", "object"]) + def test_pass_through_legacy_message(self): """parse_legacy_message should pass through already-parsed messages.""" req = LegacyRequest(method="test") diff --git a/tests_v4/test_protocol_messages.py b/tests_v4/test_protocol_messages.py index 02ea720fe..fb73dbcc4 100644 --- a/tests_v4/test_protocol_messages.py +++ b/tests_v4/test_protocol_messages.py @@ -237,6 +237,33 @@ class TestResultMessage: msg = ResultMessage(id="msg_004", success=True) assert msg.output == {} + def test_success_result_rejects_error(self): + """ResultMessage success=true should not accept error payload.""" + with pytest.raises(ValidationError) as exc_info: + ResultMessage( + id="msg_005", + success=True, + error=ErrorPayload(code="bad", message="bad"), + ) + assert "success=true 时 error 必须为空" in str(exc_info.value) + + def test_failed_result_requires_error(self): + """ResultMessage success=false should require error payload.""" + with pytest.raises(ValidationError) as exc_info: + ResultMessage(id="msg_006", success=False) + assert "success=false 时必须提供 error" in str(exc_info.value) + + def test_failed_result_rejects_output(self): + """ResultMessage success=false should not carry success output.""" + with pytest.raises(ValidationError) as exc_info: + ResultMessage( + id="msg_007", + success=False, + output={"text": "bad"}, + error=ErrorPayload(code="bad", message="bad"), + ) + assert "success=false 时 output 必须为空" in str(exc_info.value) + class TestInvokeMessage: """Tests for InvokeMessage model.""" @@ -436,6 +463,16 @@ class TestParseMessage: assert isinstance(msg, ResultMessage) assert msg.success is True + def test_parse_pass_through_model(self): + """parse_message should return already-parsed protocol models unchanged.""" + original = InvokeMessage(id="msg_008", capability="test.cap") + assert parse_message(original) is original + + def test_parse_non_mapping_raises(self): + """parse_message should reject non-object payloads.""" + with pytest.raises(ValueError, match="JSON object"): + parse_message(["not", "an", "object"]) + def test_parse_unknown_type_raises(self): """parse_message should raise for unknown type.""" with pytest.raises(ValueError) as exc_info: diff --git a/tests_v4/test_protocol_package.py b/tests_v4/test_protocol_package.py new file mode 100644 index 000000000..e8710bb0a --- /dev/null +++ b/tests_v4/test_protocol_package.py @@ -0,0 +1,57 @@ +"""Tests for protocol package exports.""" + +from __future__ import annotations + +from astrbot_sdk.protocol import ( + CapabilityDescriptor, + CommandTrigger, + ErrorPayload, + EventMessage, + HandlerDescriptor, + InitializeMessage, + LegacyAdapter, + LegacyRequest, + MessageTrigger, + PeerInfo, + ProtocolMessage, + ResultMessage, + ScheduleTrigger, + parse_legacy_message, + parse_message, +) + + +class TestProtocolPackageExports: + """Ensure protocol package exposes the intended public surface.""" + + def test_core_exports_are_importable(self): + """Core protocol models and parsers should be importable from package root.""" + handler = HandlerDescriptor( + id="demo.handler", + trigger=CommandTrigger(command="hello"), + ) + message = InitializeMessage( + id="msg-1", + protocol_version="1.0", + peer=PeerInfo(name="plugin", role="plugin"), + handlers=[handler], + ) + parsed: ProtocolMessage = parse_message(message) + + assert isinstance(parsed, InitializeMessage) + assert isinstance(ErrorPayload(code="x", message="y"), ErrorPayload) + assert isinstance( + CapabilityDescriptor(name="llm.chat", description="chat"), + CapabilityDescriptor, + ) + assert isinstance(MessageTrigger(keywords=["hello"]), MessageTrigger) + assert isinstance(ScheduleTrigger(interval_seconds=60), ScheduleTrigger) + assert isinstance(EventMessage(id="evt-1", phase="started"), EventMessage) + assert isinstance(ResultMessage(id="res-1", success=True), ResultMessage) + + def test_legacy_exports_are_importable(self): + """Legacy adapter helpers should also be available from package root.""" + legacy = parse_legacy_message({"jsonrpc": "2.0", "method": "handshake"}) + + assert isinstance(legacy, LegacyRequest) + assert isinstance(LegacyAdapter(), LegacyAdapter)