mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: 增强 v4 客户端功能,添加内存获取和错误处理,更新文档和测试用例
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
- 2026-03-13: `src-new/astrbot_sdk/api` and several top-level module docstrings overstated v4 compatibility gaps by labeling migrated or compat-backed APIs as "missing". Treat `_legacy_api.py`, `astrbot_sdk.api.*` thin re-exports, and top-level `events.py` / `decorators.py` as a split compatibility surface; do not mechanically recreate the old tree from stale TODO comments.
|
||||
- 2026-03-13: Legacy components are expected to share one `LegacyContext` per plugin, matching the old `StarManager` behavior. Creating one compat context per component breaks `_register_component()` / `call_context_function()` cross-component registration chains and diverges from legacy semantics.
|
||||
- 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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
- 2026-03-13: `src-new/astrbot_sdk/api` and several top-level module docstrings overstated v4 compatibility gaps by labeling migrated or compat-backed APIs as "missing". Treat `_legacy_api.py`, `astrbot_sdk.api.*` thin re-exports, and top-level `events.py` / `decorators.py` as a split compatibility surface; do not mechanically recreate the old tree from stale TODO comments.
|
||||
- 2026-03-13: Legacy components are expected to share one `LegacyContext` per plugin, matching the old `StarManager` behavior. Creating one compat context per component breaks `_register_component()` / `call_context_function()` cross-component registration chains and diverges from legacy semantics.
|
||||
- 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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
"""客户端模块。
|
||||
"""v4 原生能力客户端集合。
|
||||
|
||||
提供与 AstrBot 核心通信的客户端接口,通过 RPC 能力代理实现远程调用。
|
||||
所有客户端均基于 CapabilityProxy 构建,统一处理方法调用和流式响应。
|
||||
这些客户端是 `Context` 的窄接口,分别封装 llm、memory、db、platform
|
||||
四类远程 capability。它们只负责能力调用与轻量参数整形,不承载旧版
|
||||
`conversation_manager`、`MessageChain` 或 agent loop 语义;这些兼容能力由
|
||||
`_legacy_api.py` 与 `api/` compat 子模块处理。
|
||||
|
||||
架构说明:
|
||||
旧版 (src/astrbot_sdk/api/star/context.py):
|
||||
- Context 基类直接提供 llm_generate(), tool_loop_agent(), send_message() 等方法
|
||||
- 使用 MessageChain, ToolSet 等复杂类型
|
||||
- 内置 conversation_manager 会话管理
|
||||
|
||||
新版 (src-new):
|
||||
- Context 组合多个专用客户端 (llm, memory, db, platform)
|
||||
- 每个客户端负责单一领域的功能
|
||||
- 通过 Peer 和 CapabilityProxy 实现远程能力调用
|
||||
- 支持流式响应 (stream_chat)
|
||||
|
||||
可用客户端:
|
||||
- LLMClient: 大语言模型调用,支持普通/流式聊天
|
||||
- MemoryClient: 记忆存储,支持搜索、保存、获取、删除
|
||||
- DBClient: 键值数据库,支持 get/set/delete/list
|
||||
- PlatformClient: 平台消息发送,支持文本和图片消息
|
||||
|
||||
TODO: (相比旧版缺失的功能):
|
||||
- LLMClient 缺少 tool_loop_agent() Agent 循环能力
|
||||
- LLMClient 缺少 add_llm_tools() 动态工具注册
|
||||
- LLMClient.chat() 缺少 image_urls、tools、contexts 等高级参数支持
|
||||
- Context 缺少 conversation_manager 会话管理器集成
|
||||
- 缺少 MessageChain 消息链构建支持
|
||||
当前公开客户端:
|
||||
- LLMClient: 文本/结构化/流式 LLM 调用
|
||||
- MemoryClient: 记忆搜索、保存、读取、删除
|
||||
- DBClient: 键值存储 get/set/delete/list
|
||||
- PlatformClient: 平台消息发送与成员查询
|
||||
"""
|
||||
|
||||
from .db import DBClient
|
||||
|
||||
@@ -29,12 +29,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..errors import AstrBotError
|
||||
|
||||
|
||||
class _CapabilityDescriptorLike(Protocol):
|
||||
supports_stream: bool | None
|
||||
|
||||
|
||||
class _CapabilityPeerLike(Protocol):
|
||||
remote_capability_map: Mapping[str, _CapabilityDescriptorLike]
|
||||
remote_peer: Any | None
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
capability: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
stream: bool = False,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def invoke_stream(
|
||||
self,
|
||||
capability: str,
|
||||
payload: dict[str, Any],
|
||||
) -> AsyncIterator[Any]: ...
|
||||
|
||||
|
||||
class CapabilityProxy:
|
||||
"""能力代理类,封装 Peer 的能力调用接口。
|
||||
|
||||
@@ -44,7 +67,7 @@ class CapabilityProxy:
|
||||
_peer: 底层 Peer 实例,负责实际的 RPC 通信
|
||||
"""
|
||||
|
||||
def __init__(self, peer) -> None:
|
||||
def __init__(self, peer: _CapabilityPeerLike) -> None:
|
||||
"""初始化能力代理。
|
||||
|
||||
Args:
|
||||
@@ -61,7 +84,17 @@ class CapabilityProxy:
|
||||
Returns:
|
||||
能力描述符,若不存在则返回 None
|
||||
"""
|
||||
return self._peer.remote_capability_map.get(name)
|
||||
capability_map = getattr(self._peer, "__dict__", {}).get(
|
||||
"remote_capability_map",
|
||||
{},
|
||||
)
|
||||
return capability_map.get(name)
|
||||
|
||||
def _remote_initialized(self) -> bool:
|
||||
peer_state = getattr(self._peer, "__dict__", {})
|
||||
return bool(peer_state.get("remote_peer")) or bool(
|
||||
peer_state.get("remote_capability_map", {})
|
||||
)
|
||||
|
||||
def _ensure_available(self, name: str, *, stream: bool) -> None:
|
||||
"""确保能力可用且支持指定的调用模式。
|
||||
@@ -75,15 +108,11 @@ class CapabilityProxy:
|
||||
"""
|
||||
descriptor = self._get_descriptor(name)
|
||||
if descriptor is None:
|
||||
# 能力不存在,但如果远端尚未初始化则静默返回
|
||||
if self._peer.remote_capability_map:
|
||||
if self._remote_initialized():
|
||||
raise AstrBotError.capability_not_found(name)
|
||||
return
|
||||
if stream and not descriptor.supports_stream:
|
||||
raise AstrBotError.invalid_input(f"{name} 不支持 stream=true")
|
||||
if not stream and descriptor.supports_stream is False:
|
||||
# 仅支持流式的能力也可以用普通调用
|
||||
return
|
||||
|
||||
async def call(self, name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""执行普通能力调用(非流式)。
|
||||
|
||||
@@ -73,9 +73,14 @@ class DBClient:
|
||||
key: 数据键名
|
||||
value: 要存储的字典值
|
||||
|
||||
Raises:
|
||||
TypeError: 如果 value 不是 dict
|
||||
|
||||
示例:
|
||||
await ctx.db.set("user_settings", {"theme": "dark", "lang": "zh"})
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("db.set 的 value 必须是 dict")
|
||||
await self._proxy.call("db.set", {"key": key, "value": value})
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
@@ -104,4 +109,7 @@ class DBClient:
|
||||
# ["user_settings", "user_profile", "user_history"]
|
||||
"""
|
||||
output = await self._proxy.call("db.list", {"prefix": prefix})
|
||||
return [str(item) for item in output.get("keys", [])]
|
||||
keys = output.get("keys")
|
||||
if not isinstance(keys, (list, tuple)):
|
||||
return []
|
||||
return [str(item) for item in keys]
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
"""大语言模型客户端模块。
|
||||
|
||||
提供与 LLM 交互的能力,支持普通聊天和流式聊天。
|
||||
提供 v4 原生的 LLM 能力调用接口。
|
||||
|
||||
与旧版对比:
|
||||
旧版 (src/astrbot_sdk/api/star/context.py):
|
||||
Context.llm_generate(
|
||||
chat_provider_id, prompt, image_urls, tools,
|
||||
system_prompt, contexts, **kwargs
|
||||
)
|
||||
Context.tool_loop_agent(...) # Agent 循环,自动执行工具调用
|
||||
|
||||
新版:
|
||||
Context.llm.chat(prompt, system, history, model, temperature)
|
||||
Context.llm.chat_raw(prompt, **kwargs) # 返回完整响应
|
||||
Context.llm.stream_chat(prompt, system, history) # 流式响应
|
||||
|
||||
主要差异:
|
||||
1. 新版移除了 chat_provider_id 参数,由核心自动选择
|
||||
2. 新版简化了参数结构,使用 ChatMessage 模型
|
||||
3. 新版支持流式响应 (stream_chat)
|
||||
|
||||
TODO (相比旧版缺失的功能):
|
||||
- 缺少 tool_loop_agent() Agent 循环能力
|
||||
- 缺少 add_llm_tools() 动态工具注册
|
||||
- chat() 缺少 image_urls 多模态图片支持
|
||||
- chat() 缺少 tools 工具集支持
|
||||
- chat() 缺少 contexts 上下文消息列表
|
||||
- 缺少对 OpenAI 兼容的额外参数传递 (**kwargs 支持不完整)
|
||||
设计边界:
|
||||
- `chat()` 是便捷文本接口,返回最终文本
|
||||
- `chat_raw()` 返回完整结构化响应
|
||||
- `stream_chat()` 返回文本增量
|
||||
- Agent 循环、动态工具注册等更高层 orchestration 不放在客户端内,
|
||||
由上层运行时或 `_legacy_api.py` 的兼容入口承接
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -60,6 +41,50 @@ class ChatMessage(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
ChatHistoryItem = ChatMessage | Mapping[str, Any]
|
||||
|
||||
|
||||
def _serialize_history(
|
||||
history: Sequence[ChatHistoryItem] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if history is None:
|
||||
return []
|
||||
|
||||
serialized: list[dict[str, Any]] = []
|
||||
for item in history:
|
||||
if isinstance(item, ChatMessage):
|
||||
serialized.append(item.model_dump())
|
||||
continue
|
||||
if isinstance(item, Mapping):
|
||||
serialized.append(dict(item))
|
||||
continue
|
||||
raise TypeError("history 项必须是 ChatMessage 或 mapping")
|
||||
return serialized
|
||||
|
||||
|
||||
def _build_chat_payload(
|
||||
prompt: str,
|
||||
*,
|
||||
system: str | None = None,
|
||||
history: Sequence[ChatHistoryItem] | None = None,
|
||||
model: str | None = None,
|
||||
temperature: float | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"prompt": prompt}
|
||||
if system is not None:
|
||||
payload["system"] = system
|
||||
if history is not None:
|
||||
payload["history"] = _serialize_history(history)
|
||||
if model is not None:
|
||||
payload["model"] = model
|
||||
if temperature is not None:
|
||||
payload["temperature"] = temperature
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
class LLMResponse(BaseModel):
|
||||
"""LLM 响应模型。
|
||||
|
||||
@@ -100,9 +125,10 @@ class LLMClient:
|
||||
prompt: str,
|
||||
*,
|
||||
system: str | None = None,
|
||||
history: list[ChatMessage] | None = None,
|
||||
history: Sequence[ChatHistoryItem] | None = None,
|
||||
model: str | None = None,
|
||||
temperature: float | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""发送聊天请求并返回文本响应。
|
||||
|
||||
@@ -115,6 +141,7 @@ class LLMClient:
|
||||
history: 对话历史,用于保持上下文连续性
|
||||
model: 指定使用的模型名称(可选,由核心自动选择)
|
||||
temperature: 生成温度,控制随机性(0-1)
|
||||
**kwargs: 额外透传参数,如 `image_urls`、`tools`
|
||||
|
||||
Returns:
|
||||
LLM 生成的文本内容
|
||||
@@ -132,13 +159,14 @@ class LLMClient:
|
||||
"""
|
||||
output = await self._proxy.call(
|
||||
"llm.chat",
|
||||
{
|
||||
"prompt": prompt,
|
||||
"system": system,
|
||||
"history": [item.model_dump() for item in history or []],
|
||||
"model": model,
|
||||
"temperature": temperature,
|
||||
},
|
||||
_build_chat_payload(
|
||||
prompt,
|
||||
system=system,
|
||||
history=history,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
extra=kwargs,
|
||||
),
|
||||
)
|
||||
return str(output.get("text", ""))
|
||||
|
||||
@@ -164,12 +192,12 @@ class LLMClient:
|
||||
print(f"生成文本: {response.text}")
|
||||
print(f"Token 使用: {response.usage}")
|
||||
"""
|
||||
payload = {"prompt": prompt, **kwargs}
|
||||
if "history" in payload:
|
||||
payload["history"] = _serialize_history(payload["history"])
|
||||
output = await self._proxy.call(
|
||||
"llm.chat_raw",
|
||||
{
|
||||
"prompt": prompt,
|
||||
**kwargs,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
return LLMResponse.model_validate(output)
|
||||
|
||||
@@ -178,7 +206,10 @@ class LLMClient:
|
||||
prompt: str,
|
||||
*,
|
||||
system: str | None = None,
|
||||
history: list[ChatMessage] | None = None,
|
||||
history: Sequence[ChatHistoryItem] | None = None,
|
||||
model: str | None = None,
|
||||
temperature: float | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式聊天,逐块返回响应文本。
|
||||
|
||||
@@ -188,6 +219,9 @@ class LLMClient:
|
||||
prompt: 用户输入的提示文本
|
||||
system: 系统提示词
|
||||
history: 对话历史
|
||||
model: 指定模型
|
||||
temperature: 采样温度
|
||||
**kwargs: 额外透传参数,如 `image_urls`、`tools`
|
||||
|
||||
Yields:
|
||||
每个生成的文本块
|
||||
@@ -198,10 +232,13 @@ class LLMClient:
|
||||
"""
|
||||
async for data in self._proxy.stream(
|
||||
"llm.stream_chat",
|
||||
{
|
||||
"prompt": prompt,
|
||||
"system": system,
|
||||
"history": [item.model_dump() for item in history or []],
|
||||
},
|
||||
_build_chat_payload(
|
||||
prompt,
|
||||
system=system,
|
||||
history=history,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
extra=kwargs,
|
||||
),
|
||||
):
|
||||
yield str(data.get("text", ""))
|
||||
|
||||
@@ -70,7 +70,10 @@ class MemoryClient:
|
||||
print(item["key"], item["content"])
|
||||
"""
|
||||
output = await self._proxy.call("memory.search", {"query": query})
|
||||
return list(output.get("items", []))
|
||||
items = output.get("items")
|
||||
if not isinstance(items, (list, tuple)):
|
||||
return []
|
||||
return list(items)
|
||||
|
||||
async def save(
|
||||
self,
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
"""平台客户端模块。
|
||||
|
||||
提供与聊天平台交互的能力,支持发送消息和获取群组信息。
|
||||
提供 v4 原生的平台能力调用。
|
||||
|
||||
与旧版对比:
|
||||
旧版 (src/astrbot_sdk/api/star/context.py):
|
||||
Context.send_message(session, message_chain)
|
||||
# 使用 MessageChain 构建复杂消息
|
||||
|
||||
新版:
|
||||
Context.platform.send(session, text)
|
||||
Context.platform.send_image(session, image_url)
|
||||
Context.platform.get_members(session)
|
||||
|
||||
主要差异:
|
||||
1. 新版拆分为 send() 和 send_image(),简化使用
|
||||
2. 新版移除 MessageChain,直接使用文本字符串
|
||||
3. 新增 get_members() 获取群成员列表
|
||||
|
||||
TODO (相比旧版缺失的功能):
|
||||
- 缺少 MessageChain 复杂消息链支持(多段文本、@提及、表情等)
|
||||
- 缺少发送音频、视频、文件等多媒体消息
|
||||
- 缺少消息撤回、编辑等操作
|
||||
- 缺少获取用户详细信息的方法
|
||||
设计边界:
|
||||
- `PlatformClient` 只负责直接的平台 capability
|
||||
- 旧版 `send_message(session, MessageChain)` 兼容由 `_legacy_api.py` 承接
|
||||
- 富消息链构建能力位于 `api.message` compat 子模块,而不是此客户端
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -62,7 +46,7 @@ class PlatformClient:
|
||||
|
||||
示例:
|
||||
# 发送消息到当前会话
|
||||
await ctx.platform.send(event.session, "收到您的消息!")
|
||||
await ctx.platform.send(event.session_id, "收到您的消息!")
|
||||
"""
|
||||
return await self._proxy.call(
|
||||
"platform.send",
|
||||
@@ -83,7 +67,7 @@ class PlatformClient:
|
||||
|
||||
示例:
|
||||
await ctx.platform.send_image(
|
||||
event.session,
|
||||
event.session_id,
|
||||
"https://example.com/image.png"
|
||||
)
|
||||
"""
|
||||
@@ -107,9 +91,12 @@ class PlatformClient:
|
||||
- role: 角色 (owner, admin, member)
|
||||
|
||||
示例:
|
||||
members = await ctx.platform.get_members(event.session)
|
||||
members = await ctx.platform.get_members(event.session_id)
|
||||
for member in members:
|
||||
print(f"{member['nickname']} ({member['user_id']})")
|
||||
"""
|
||||
output = await self._proxy.call("platform.get_members", {"session": session})
|
||||
return list(output.get("members", []))
|
||||
members = output.get("members")
|
||||
if not isinstance(members, (list, tuple)):
|
||||
return []
|
||||
return list(members)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""v4 原生运行时上下文。"""
|
||||
"""v4 原生运行时上下文。
|
||||
|
||||
`Context` 负责组合 v4 原生 capability 客户端。
|
||||
旧版 `conversation_manager`、`send_message()` 等兼容入口不在这里实现,
|
||||
而由 `_legacy_api.py` 承接。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,6 +49,7 @@ class Context:
|
||||
logger: Any | None = None,
|
||||
) -> None:
|
||||
proxy = CapabilityProxy(peer)
|
||||
self.peer = peer
|
||||
self.llm = LLMClient(proxy)
|
||||
self.memory = MemoryClient(proxy)
|
||||
self.db = DBClient(proxy)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
llm.stream_chat: 流式 LLM 聊天
|
||||
memory.search: 搜索记忆
|
||||
memory.save: 保存记忆
|
||||
memory.get: 读取单条记忆
|
||||
memory.delete: 删除记忆
|
||||
db.get: 读取 KV 存储
|
||||
db.set: 写入 KV 存储
|
||||
@@ -250,6 +251,11 @@ class CapabilityRouter:
|
||||
self.memory_store[key] = value
|
||||
return {}
|
||||
|
||||
async def memory_get(
|
||||
_request_id: str, payload: dict[str, Any], _token
|
||||
) -> dict[str, Any]:
|
||||
return {"value": self.memory_store.get(str(payload.get("key", "")))}
|
||||
|
||||
async def memory_delete(
|
||||
_request_id: str, payload: dict[str, Any], _token
|
||||
) -> dict[str, Any]:
|
||||
@@ -379,6 +385,15 @@ class CapabilityRouter:
|
||||
),
|
||||
call_handler=memory_save,
|
||||
)
|
||||
self.register(
|
||||
CapabilityDescriptor(
|
||||
name="memory.get",
|
||||
description="读取单条记忆",
|
||||
input_schema=obj_schema(["key"], key={"type": "string"}),
|
||||
output_schema=obj_schema([], value={"type": "object"}),
|
||||
),
|
||||
call_handler=memory_get,
|
||||
)
|
||||
self.register(
|
||||
CapabilityDescriptor(
|
||||
name="memory.delete",
|
||||
|
||||
@@ -26,6 +26,7 @@ class MockPeer:
|
||||
|
||||
def __init__(self):
|
||||
self.remote_capability_map: dict[str, MockCapabilityDescriptor] = {}
|
||||
self.remote_peer = None
|
||||
self.invoke = AsyncMock(return_value={"result": "ok"})
|
||||
self.invoke_stream = AsyncMock()
|
||||
|
||||
@@ -104,11 +105,24 @@ class TestCapabilityProxyEnsureAvailable:
|
||||
"""_ensure_available should pass (return None) when capability map is empty."""
|
||||
peer = MagicMock()
|
||||
peer.remote_capability_map = {}
|
||||
peer.remote_peer = None
|
||||
proxy = CapabilityProxy(peer)
|
||||
|
||||
# Should not raise when map is empty
|
||||
proxy._ensure_available("any.cap", stream=False)
|
||||
|
||||
def test_ensure_available_raises_when_remote_initialized_without_capability(self):
|
||||
"""空 capability 表在远端已初始化后应视为真实缺失。"""
|
||||
peer = MagicMock()
|
||||
peer.remote_capability_map = {}
|
||||
peer.remote_peer = object()
|
||||
proxy = CapabilityProxy(peer)
|
||||
|
||||
with pytest.raises(AstrBotError) as exc_info:
|
||||
proxy._ensure_available("missing.cap", stream=False)
|
||||
|
||||
assert exc_info.value.code == "capability_not_found"
|
||||
|
||||
def test_ensure_available_raises_for_stream_not_supported(self):
|
||||
"""_ensure_available should raise when stream requested but not supported."""
|
||||
peer = MagicMock()
|
||||
|
||||
@@ -153,6 +153,7 @@ class TestCapabilityRouterInit:
|
||||
# Memory capabilities
|
||||
assert "memory.search" in capability_names
|
||||
assert "memory.save" in capability_names
|
||||
assert "memory.get" in capability_names
|
||||
assert "memory.delete" in capability_names
|
||||
|
||||
# DB capabilities
|
||||
@@ -490,8 +491,8 @@ class TestBuiltinMemoryCapabilities:
|
||||
"""Tests for built-in memory capabilities."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_save_and_search(self):
|
||||
"""memory.save and memory.search should work together."""
|
||||
async def test_memory_save_and_get(self):
|
||||
"""memory.save and memory.get should work together."""
|
||||
router = CapabilityRouter()
|
||||
token = CancelToken()
|
||||
|
||||
@@ -504,7 +505,31 @@ class TestBuiltinMemoryCapabilities:
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
# Search
|
||||
# Get
|
||||
result = await router.execute(
|
||||
"memory.get",
|
||||
{"key": "test_key"},
|
||||
stream=False,
|
||||
cancel_token=token,
|
||||
request_id="req-2",
|
||||
)
|
||||
|
||||
assert result["value"] == {"data": "test_value"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_save_and_search(self):
|
||||
"""memory.save and memory.search should work together."""
|
||||
router = CapabilityRouter()
|
||||
token = CancelToken()
|
||||
|
||||
await router.execute(
|
||||
"memory.save",
|
||||
{"key": "test_key", "value": {"data": "test_value"}},
|
||||
stream=False,
|
||||
cancel_token=token,
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
result = await router.execute(
|
||||
"memory.search",
|
||||
{"query": "test"},
|
||||
@@ -516,6 +541,22 @@ class TestBuiltinMemoryCapabilities:
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["key"] == "test_key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_get_missing_key(self):
|
||||
"""memory.get should return None for missing key."""
|
||||
router = CapabilityRouter()
|
||||
token = CancelToken()
|
||||
|
||||
result = await router.execute(
|
||||
"memory.get",
|
||||
{"key": "missing"},
|
||||
stream=False,
|
||||
cancel_token=token,
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
assert result["value"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_delete(self):
|
||||
"""memory.delete should remove saved memory."""
|
||||
|
||||
@@ -112,6 +112,18 @@ class TestContext:
|
||||
|
||||
assert ctx.plugin_id == "my_plugin"
|
||||
|
||||
def test_context_keeps_peer_reference(self, transport_pair):
|
||||
"""Context should retain the underlying peer for advanced diagnostics."""
|
||||
left, _ = transport_pair
|
||||
|
||||
peer = Peer(
|
||||
transport=left,
|
||||
peer_info=PeerInfo(name="test", role="plugin", version="v4"),
|
||||
)
|
||||
ctx = Context(peer=peer, plugin_id="my_plugin")
|
||||
|
||||
assert ctx.peer is peer
|
||||
|
||||
def test_context_has_logger(self, transport_pair):
|
||||
"""Context should have a logger bound with plugin_id."""
|
||||
left, _ = transport_pair
|
||||
|
||||
@@ -125,6 +125,15 @@ class TestDBClientSet:
|
||||
{"key": "nested", "value": {"level1": {"level2": {"level3": "deep"}}}},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_raises_type_error_for_non_dict_value(self):
|
||||
"""set() should reject non-dict values before calling proxy."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
client = DBClient(proxy)
|
||||
|
||||
with pytest.raises(TypeError, match="db.set 的 value 必须是 dict"):
|
||||
await client.set("bad", "not a dict")
|
||||
|
||||
|
||||
class TestDBClientDelete:
|
||||
"""Tests for DBClient.delete() method."""
|
||||
@@ -201,6 +210,17 @@ class TestDBClientList:
|
||||
|
||||
assert result == ["123", "456"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_empty_list_for_malformed_keys(self):
|
||||
"""list() should ignore malformed non-list key payloads."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"keys": "not-a-list"})
|
||||
|
||||
client = DBClient(proxy)
|
||||
result = await client.list()
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_with_none_prefix(self):
|
||||
"""list() should handle None prefix."""
|
||||
|
||||
@@ -123,6 +123,25 @@ class TestLLMClientChat:
|
||||
assert len(call_args["history"]) == 2
|
||||
assert call_args["history"][0] == {"role": "user", "content": "Hello"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_accepts_dict_history_and_extra_kwargs(self):
|
||||
"""chat() should normalize dict history items and pass through extras."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"text": "OK"})
|
||||
|
||||
client = LLMClient(proxy)
|
||||
await client.chat(
|
||||
"How are you?",
|
||||
history=[{"role": "user", "content": "Hello"}],
|
||||
image_urls=["https://example.com/a.png"],
|
||||
tools=[{"name": "search"}],
|
||||
)
|
||||
|
||||
call_args = proxy.call.call_args[0][1]
|
||||
assert call_args["history"] == [{"role": "user", "content": "Hello"}]
|
||||
assert call_args["image_urls"] == ["https://example.com/a.png"]
|
||||
assert call_args["tools"] == [{"name": "search"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_model_and_temperature(self):
|
||||
"""chat() should pass model and temperature."""
|
||||
@@ -184,6 +203,21 @@ class TestLLMClientChatRaw:
|
||||
assert call_args["custom_param"] == "value"
|
||||
assert call_args["another"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_raw_normalizes_history_items(self):
|
||||
"""chat_raw() should serialize ChatMessage history items before proxy call."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"text": "OK"})
|
||||
|
||||
client = LLMClient(proxy)
|
||||
await client.chat_raw(
|
||||
"Test",
|
||||
history=[ChatMessage(role="user", content="Hello")],
|
||||
)
|
||||
|
||||
call_args = proxy.call.call_args[0][1]
|
||||
assert call_args["history"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
|
||||
class TestLLMClientStreamChat:
|
||||
"""Tests for LLMClient.stream_chat() method."""
|
||||
@@ -232,6 +266,33 @@ class TestLLMClientStreamChat:
|
||||
assert captured_payload["system"] == "Be nice"
|
||||
assert len(captured_payload["history"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_chat_passes_extra_kwargs(self):
|
||||
"""stream_chat() should pass through advanced kwargs."""
|
||||
proxy = MagicMock(spec=CapabilityProxy)
|
||||
|
||||
captured_payload = None
|
||||
|
||||
async def mock_stream(name, payload):
|
||||
nonlocal captured_payload
|
||||
captured_payload = payload
|
||||
yield {"text": "Done"}
|
||||
|
||||
proxy.stream = mock_stream
|
||||
|
||||
client = LLMClient(proxy)
|
||||
chunks = []
|
||||
async for chunk in client.stream_chat(
|
||||
"Test",
|
||||
image_urls=["https://example.com/a.png"],
|
||||
tools=[{"name": "search"}],
|
||||
):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert chunks == ["Done"]
|
||||
assert captured_payload["image_urls"] == ["https://example.com/a.png"]
|
||||
assert captured_payload["tools"] == [{"name": "search"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_chat_yields_empty_string_for_missing_text(self):
|
||||
"""stream_chat() should yield empty string if text is missing."""
|
||||
|
||||
@@ -71,6 +71,17 @@ class TestMemoryClientSearch:
|
||||
proxy.call.assert_called_once_with("memory.search", {"query": ""})
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_returns_empty_list_for_malformed_items(self):
|
||||
"""search() should ignore malformed non-list item payloads."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"items": "bad"})
|
||||
|
||||
client = MemoryClient(proxy)
|
||||
result = await client.search("test")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestMemoryClientSave:
|
||||
"""Tests for MemoryClient.save() method."""
|
||||
@@ -160,6 +171,44 @@ class TestMemoryClientSave:
|
||||
await client.save("key", [1, 2, 3])
|
||||
|
||||
|
||||
class TestMemoryClientGet:
|
||||
"""Tests for MemoryClient.get() method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_dict_value(self):
|
||||
"""get() should return dict value from proxy response."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"value": {"theme": "dark"}})
|
||||
|
||||
client = MemoryClient(proxy)
|
||||
result = await client.get("user_pref")
|
||||
|
||||
proxy.call.assert_called_once_with("memory.get", {"key": "user_pref"})
|
||||
assert result == {"theme": "dark"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_none_for_missing_value(self):
|
||||
"""get() should return None when memory is absent."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"value": None})
|
||||
|
||||
client = MemoryClient(proxy)
|
||||
result = await client.get("missing")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_none_for_non_dict_value(self):
|
||||
"""get() should ignore malformed non-dict payloads."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"value": "bad"})
|
||||
|
||||
client = MemoryClient(proxy)
|
||||
result = await client.get("bad")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestMemoryClientDelete:
|
||||
"""Tests for MemoryClient.delete() method."""
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class TestPlatformClientSend:
|
||||
proxy.call = AsyncMock(return_value={})
|
||||
|
||||
client = PlatformClient(proxy)
|
||||
result = await client.send("session-1", "")
|
||||
await client.send("session-1", "")
|
||||
|
||||
call_args = proxy.call.call_args[0][1]
|
||||
assert call_args["text"] == ""
|
||||
@@ -145,6 +145,17 @@ class TestPlatformClientGetMembers:
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_members_returns_empty_list_for_malformed_payload(self):
|
||||
"""get_members() should ignore malformed non-list payloads."""
|
||||
proxy = AsyncMock(spec=CapabilityProxy)
|
||||
proxy.call = AsyncMock(return_value={"members": "bad"})
|
||||
|
||||
client = PlatformClient(proxy)
|
||||
result = await client.get_members("group-1")
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_members_with_private_session(self):
|
||||
"""get_members() should work with private session."""
|
||||
@@ -152,7 +163,7 @@ class TestPlatformClientGetMembers:
|
||||
proxy.call = AsyncMock(return_value={"members": [{"id": "single_user"}]})
|
||||
|
||||
client = PlatformClient(proxy)
|
||||
result = await client.get_members("private-123")
|
||||
await client.get_members("private-123")
|
||||
|
||||
call_args = proxy.call.call_args[0][1]
|
||||
assert call_args["session"] == "private-123"
|
||||
|
||||
Reference in New Issue
Block a user