diff --git a/AGENTS.md b/AGENTS.md index f6c7f2f4d..1db68c203 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index f6c7f2f4d..1db68c203 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. # 开发命令 diff --git a/src-new/astrbot_sdk/clients/__init__.py b/src-new/astrbot_sdk/clients/__init__.py index d7c0861ff..ec1a647b3 100644 --- a/src-new/astrbot_sdk/clients/__init__.py +++ b/src-new/astrbot_sdk/clients/__init__.py @@ -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 diff --git a/src-new/astrbot_sdk/clients/_proxy.py b/src-new/astrbot_sdk/clients/_proxy.py index 0d34cfb27..40ca49682 100644 --- a/src-new/astrbot_sdk/clients/_proxy.py +++ b/src-new/astrbot_sdk/clients/_proxy.py @@ -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]: """执行普通能力调用(非流式)。 diff --git a/src-new/astrbot_sdk/clients/db.py b/src-new/astrbot_sdk/clients/db.py index 9d43a288b..6ddf0c1f9 100644 --- a/src-new/astrbot_sdk/clients/db.py +++ b/src-new/astrbot_sdk/clients/db.py @@ -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] diff --git a/src-new/astrbot_sdk/clients/llm.py b/src-new/astrbot_sdk/clients/llm.py index 9706b310d..9456d9b35 100644 --- a/src-new/astrbot_sdk/clients/llm.py +++ b/src-new/astrbot_sdk/clients/llm.py @@ -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", "")) diff --git a/src-new/astrbot_sdk/clients/memory.py b/src-new/astrbot_sdk/clients/memory.py index 4dd91cde2..00d9dfcbf 100644 --- a/src-new/astrbot_sdk/clients/memory.py +++ b/src-new/astrbot_sdk/clients/memory.py @@ -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, diff --git a/src-new/astrbot_sdk/clients/platform.py b/src-new/astrbot_sdk/clients/platform.py index dbf4470f3..7d1710221 100644 --- a/src-new/astrbot_sdk/clients/platform.py +++ b/src-new/astrbot_sdk/clients/platform.py @@ -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) diff --git a/src-new/astrbot_sdk/context.py b/src-new/astrbot_sdk/context.py index ab749e135..a56fe642e 100644 --- a/src-new/astrbot_sdk/context.py +++ b/src-new/astrbot_sdk/context.py @@ -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) diff --git a/src-new/astrbot_sdk/runtime/capability_router.py b/src-new/astrbot_sdk/runtime/capability_router.py index 36bfd59fb..a06adf5be 100644 --- a/src-new/astrbot_sdk/runtime/capability_router.py +++ b/src-new/astrbot_sdk/runtime/capability_router.py @@ -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", diff --git a/tests_v4/test_capability_proxy.py b/tests_v4/test_capability_proxy.py index 59627dea6..ba3f22937 100644 --- a/tests_v4/test_capability_proxy.py +++ b/tests_v4/test_capability_proxy.py @@ -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() diff --git a/tests_v4/test_capability_router.py b/tests_v4/test_capability_router.py index 1cb5e664c..f50a25b35 100644 --- a/tests_v4/test_capability_router.py +++ b/tests_v4/test_capability_router.py @@ -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.""" diff --git a/tests_v4/test_context.py b/tests_v4/test_context.py index 5b6c5f9a1..088485fab 100644 --- a/tests_v4/test_context.py +++ b/tests_v4/test_context.py @@ -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 diff --git a/tests_v4/test_db_client.py b/tests_v4/test_db_client.py index 86a230ecd..33403ec18 100644 --- a/tests_v4/test_db_client.py +++ b/tests_v4/test_db_client.py @@ -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.""" diff --git a/tests_v4/test_llm_client.py b/tests_v4/test_llm_client.py index 4fd02a20d..6d6669089 100644 --- a/tests_v4/test_llm_client.py +++ b/tests_v4/test_llm_client.py @@ -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.""" diff --git a/tests_v4/test_memory_client.py b/tests_v4/test_memory_client.py index 2a1b40052..d5d6f4a6b 100644 --- a/tests_v4/test_memory_client.py +++ b/tests_v4/test_memory_client.py @@ -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.""" diff --git a/tests_v4/test_platform_client.py b/tests_v4/test_platform_client.py index eeb679898..b072fc17c 100644 --- a/tests_v4/test_platform_client.py +++ b/tests_v4/test_platform_client.py @@ -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"