feat: 增强 v4 客户端功能,添加内存获取和错误处理,更新文档和测试用例

This commit is contained in:
whatevertogo
2026-03-13 04:02:07 +08:00
parent 7cab1cd432
commit ae5d6dc692
17 changed files with 394 additions and 114 deletions

View File

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

View File

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

View File

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

View File

@@ -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]:
"""执行普通能力调用(非流式)。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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