chore: 迁移服务器前同步本地修改

This commit is contained in:
2026-08-05 21:26:13 +08:00
parent aa47a19e98
commit 1a914a2517
73 changed files with 908 additions and 1961 deletions
+20 -40
View File
@@ -1,76 +1,56 @@
"""Pytest 全局 fixture:把 Ray Actor 句柄、PostgresDatabase、loguru 等重副作用模块替换成可控的 stub。"""
"""Pytest 全局 fixture:把 actor 句柄、PostgresDatabase、loguru 等重副作用替换成可控 stub。"""
from __future__ import annotations
import sys
import types
from typing import Any, Dict, Optional
from typing import Any, Dict
from unittest.mock import AsyncMock, MagicMock
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Ray actor 句柄存根:测试期不真正连 Ray,由 conftest 注入名字 -> AsyncMock 句柄
# actor 注册表存根:测试期把名字 -> mock 实例塞进进程内 actor 运行时。
# 生产代码 ``get_actor(name).method(args)`` 会拿到 LocalActorHandle 转发到 mock。
# mock 方法用 AsyncMock 即可(handle 统一返回 awaitable)。
# ─────────────────────────────────────────────────────────────────────────────
class _FakeActorRegistry:
"""模拟 ``ray.get_actor`` 行为:测试可往里塞名字 -> AsyncMock。"""
"""薄封装:``register(name, instance)`` 直接登记到真实 actor 运行时。"""
def __init__(self) -> None:
self._actors: Dict[str, Any] = {}
def register(self, name: str, instance: Any) -> None:
from kilostar.utils import actor
def register(self, name: str, handle: Any) -> None:
self._actors[name] = handle
def get(self, name: str, namespace: str = "kilostar"): # noqa: ARG002
if name not in self._actors:
raise ValueError(f"FakeActorRegistry: actor {name!r} not registered")
return self._actors[name]
def clear(self) -> None:
self._actors.clear()
actor.register_actor(name, instance)
@pytest.fixture
def fake_actors(monkeypatch) -> _FakeActorRegistry:
"""把 ``kilostar.utils.ray_hook._get_cached_actor_handle`` 的实现替换为 fake registry。
"""清空并接管进程内 actor 运行时,测试结束后复位。
用法::
def test_xxx(fake_actors):
gsm = AsyncMock()
gsm.get_tool_config.remote = AsyncMock(return_value={"api_key": "k"})
fake_actors.register("global_state_machine", types.SimpleNamespace(global_state_machine=gsm))
gsm = MagicMock()
gsm.get_tool_config = AsyncMock(return_value={"api_key": "k"})
fake_actors.register("global_state_machine", gsm)
"""
registry = _FakeActorRegistry()
from kilostar.utils import actor
from kilostar.utils import ray_hook
ray_hook.clear_actor_cache()
original = ray_hook._get_cached_actor_handle
def _stub(actor_name: str):
return registry.get(actor_name)
_stub.cache_clear = lambda: None # type: ignore[attr-defined]
monkeypatch.setattr(ray_hook, "_get_cached_actor_handle", _stub)
yield registry
registry.clear()
monkeypatch.setattr(ray_hook, "_get_cached_actor_handle", original)
ray_hook.clear_actor_cache()
actor.clear_actors()
yield _FakeActorRegistry()
actor.clear_actors()
@pytest.fixture
def gsm_handle(fake_actors) -> MagicMock:
"""快捷 fixture:注册一个名为 ``global_state_machine`` 的 actor,返回其内部 mock。
"""快捷 fixture:注册名为 ``global_state_machine`` 的 mock 实例并返回它。
内部 mock 的方法默认全部是 ``AsyncMock``,调用 ``.remote(...)`` 会按 AsyncMock 规则返回。
方法默认为 ``AsyncMock``;生产侧 ``await get_actor(...).method(args)`` 正常工作。
"""
gsm = MagicMock()
container = types.SimpleNamespace(global_state_machine=gsm)
fake_actors.register("global_state_machine", container)
fake_actors.register("global_state_machine", gsm)
return gsm
+5 -1
View File
@@ -14,9 +14,13 @@ import pytest
from kilostar.adapter.model_adapter import agent_factory as af_mod
from kilostar.adapter.model_adapter.agent_factory import AgentFactory
from kilostar.core.global_state_machine.model_provider.base_provider import Provider
from kilostar.utils.agent_model import DepsModel, ResponseModel
from pydantic import BaseModel as _BaseModel
from kilostar.utils.error import ModelNotExistError
ResponseModel = _BaseModel
DepsModel = _BaseModel
class _SpyProvider:
last_init: Dict[str, Any] = {}
+4 -4
View File
@@ -85,7 +85,7 @@ def test_update_none_affinity_ok():
@pytest.mark.asyncio
async def test_list_templates(app, fake_actors):
pg = types.SimpleNamespace(
list_templates=types.SimpleNamespace(remote=AsyncMock(return_value=[]))
list_templates=AsyncMock(return_value=[])
)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
@@ -98,7 +98,7 @@ async def test_list_templates(app, fake_actors):
async def test_create_template(app, fake_actors):
tpl = _tpl()
pg = types.SimpleNamespace(
add_template=types.SimpleNamespace(remote=AsyncMock(return_value=tpl))
add_template=AsyncMock(return_value=tpl)
)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
@@ -110,7 +110,7 @@ async def test_create_template(app, fake_actors):
@pytest.mark.asyncio
async def test_delete_template_not_found(app, fake_actors):
pg = types.SimpleNamespace(
get_template=types.SimpleNamespace(remote=AsyncMock(return_value=None))
get_template=AsyncMock(return_value=None)
)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
@@ -121,7 +121,7 @@ async def test_delete_template_not_found(app, fake_actors):
@pytest.mark.asyncio
async def test_delete_other_users_template_forbidden(app, fake_actors):
pg = types.SimpleNamespace(
get_template=types.SimpleNamespace(remote=AsyncMock(return_value=_tpl(owner="bob")))
get_template=AsyncMock(return_value=_tpl(owner="bob"))
)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
+7 -28
View File
@@ -7,22 +7,13 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
class _FakeActorRef:
def __init__(self, target):
self._target = target
def __getattr__(self, item):
return getattr(self._target, item)
@pytest.mark.asyncio
async def test_ask_regulatory_calls_working_and_extracts_reply(monkeypatch):
async def test_ask_regulatory_calls_working_and_extracts_reply(fake_actors):
from kilostar.api import chat as chat_module
from kilostar.core.individual.regulatory_node.template import MessageResponse
@@ -31,14 +22,8 @@ async def test_ask_regulatory_calls_working_and_extracts_reply(monkeypatch):
)
regulatory = MagicMock()
regulatory.working = MagicMock()
regulatory.working.remote = AsyncMock(return_value=fake_resp)
def _fake_hook(name):
assert name == "regulatory_node"
return SimpleNamespace(regulatory_node=_FakeActorRef(regulatory))
monkeypatch.setattr(chat_module, "ray_actor_hook", _fake_hook)
regulatory.working = AsyncMock(return_value=fake_resp)
fake_actors.register("regulatory_node", regulatory)
out = await chat_module._ask_regulatory(
user_id="alice", chat_id="chat-1", message="hi"
@@ -46,7 +31,7 @@ async def test_ask_regulatory_calls_working_and_extracts_reply(monkeypatch):
assert out == "你好"
# 调用契约:MessageRequest,且 platform_id 取自 chat_id
args, kwargs = regulatory.working.remote.call_args
args, kwargs = regulatory.working.call_args
payload = args[0] if args else kwargs.get("payload")
assert payload.platform == "client"
assert payload.user_name == "alice"
@@ -55,19 +40,13 @@ async def test_ask_regulatory_calls_working_and_extracts_reply(monkeypatch):
@pytest.mark.asyncio
async def test_ask_regulatory_returns_none_when_node_returns_none(monkeypatch):
async def test_ask_regulatory_returns_none_when_node_returns_none(fake_actors):
"""节点降级返回 None 时,上层应静默不写回 chat history。"""
from kilostar.api import chat as chat_module
regulatory = MagicMock()
regulatory.working = MagicMock()
regulatory.working.remote = AsyncMock(return_value=None)
monkeypatch.setattr(
chat_module,
"ray_actor_hook",
lambda name: SimpleNamespace(regulatory_node=_FakeActorRef(regulatory)),
)
regulatory.working = AsyncMock(return_value=None)
fake_actors.register("regulatory_node", regulatory)
out = await chat_module._ask_regulatory(
user_id="bob", chat_id="chat-2", message="hello"
+10 -20
View File
@@ -39,10 +39,8 @@ async def test_get_custom_toolset_forbidden_for_non_owner(
app_with_user, fake_actors
):
gsm = types.SimpleNamespace()
gsm.get_custom_toolset = types.SimpleNamespace(
remote=AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "bob", "tools": []}
)
gsm.get_custom_toolset = AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "bob", "tools": []}
)
fake_actors.register("global_state_machine", gsm)
@@ -56,10 +54,8 @@ async def test_get_custom_toolset_forbidden_for_non_owner(
@pytest.mark.asyncio
async def test_get_custom_toolset_allowed_for_owner(app_with_user, fake_actors):
gsm = types.SimpleNamespace()
gsm.get_custom_toolset = types.SimpleNamespace(
remote=AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "alice", "tools": []}
)
gsm.get_custom_toolset = AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "alice", "tools": []}
)
fake_actors.register("global_state_machine", gsm)
@@ -76,10 +72,8 @@ async def test_get_custom_toolset_allowed_for_admin(
app_with_user, fake_actors, monkeypatch
):
gsm = types.SimpleNamespace()
gsm.get_custom_toolset = types.SimpleNamespace(
remote=AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "bob", "tools": []}
)
gsm.get_custom_toolset = AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "bob", "tools": []}
)
fake_actors.register("global_state_machine", gsm)
@@ -103,9 +97,7 @@ async def test_list_custom_toolsets_filters_by_owner(app_with_user, fake_actors)
{"toolset_id": "t2", "owner_id": "bob"},
]
gsm = types.SimpleNamespace()
gsm.list_custom_toolsets = types.SimpleNamespace(
remote=AsyncMock(return_value=all_sets)
)
gsm.list_custom_toolsets = AsyncMock(return_value=all_sets)
fake_actors.register("global_state_machine", gsm)
transport = ASGITransport(app=app_with_user)
@@ -123,13 +115,11 @@ async def test_delete_custom_toolset_forbidden_for_non_owner(
app_with_user, fake_actors
):
gsm = types.SimpleNamespace()
gsm.get_custom_toolset = types.SimpleNamespace(
remote=AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "bob"}
)
gsm.get_custom_toolset = AsyncMock(
return_value={"toolset_id": "t1", "owner_id": "bob"}
)
delete_mock = AsyncMock(return_value=True)
gsm.delete_custom_toolset = types.SimpleNamespace(remote=delete_mock)
gsm.delete_custom_toolset = delete_mock
fake_actors.register("global_state_machine", gsm)
transport = ASGITransport(app=app_with_user)
+4 -6
View File
@@ -31,11 +31,11 @@ async def test_liveness_returns_alive(health_app):
@pytest.mark.asyncio
async def test_readiness_all_ok(health_app, fake_actors):
pg = types.SimpleNamespace()
pg.ping = types.SimpleNamespace(remote=AsyncMock(return_value=True))
pg.ping = AsyncMock(return_value=True)
fake_actors.register("postgres_database", pg)
gsm = types.SimpleNamespace()
gsm.get_skill_list = types.SimpleNamespace(remote=AsyncMock(return_value=[]))
gsm.get_skill_list = AsyncMock(return_value=[])
fake_actors.register("global_state_machine", gsm)
transport = ASGITransport(app=health_app)
@@ -51,13 +51,11 @@ async def test_readiness_all_ok(health_app, fake_actors):
@pytest.mark.asyncio
async def test_readiness_postgres_down(health_app, fake_actors):
pg = types.SimpleNamespace()
pg.ping = types.SimpleNamespace(
remote=AsyncMock(side_effect=RuntimeError("db down"))
)
pg.ping = AsyncMock(side_effect=RuntimeError("db down"))
fake_actors.register("postgres_database", pg)
gsm = types.SimpleNamespace()
gsm.get_skill_list = types.SimpleNamespace(remote=AsyncMock(return_value=[]))
gsm.get_skill_list = AsyncMock(return_value=[])
fake_actors.register("global_state_machine", gsm)
transport = ASGITransport(app=health_app)
+5 -5
View File
@@ -78,7 +78,7 @@ def test_extract_plain_text_handles_unknown_type():
def regulatory_actor(fake_actors):
"""注入一个 regulatory_node Mock,默认返回固定的 MessageResponse。"""
inner = MagicMock()
inner.working.remote = AsyncMock(
inner.working = AsyncMock(
return_value=MessageResponse(
platform="onebot", platform_id="private:1234", reply_message="pong"
)
@@ -92,7 +92,7 @@ async def test_dispatch_event_ignores_non_message(regulatory_actor):
{"post_type": "meta_event", "meta_event_type": "heartbeat"}
)
assert res is None
regulatory_actor.working.remote.assert_not_called()
regulatory_actor.working.assert_not_called()
async def test_dispatch_event_ignores_empty_text(regulatory_actor):
@@ -105,7 +105,7 @@ async def test_dispatch_event_ignores_empty_text(regulatory_actor):
}
)
assert res is None
regulatory_actor.working.remote.assert_not_called()
regulatory_actor.working.assert_not_called()
async def test_dispatch_event_private_message_returns_quick_reply(regulatory_actor):
@@ -140,7 +140,7 @@ async def test_dispatch_event_group_message_includes_at_sender(regulatory_actor)
async def test_dispatch_event_swallows_actor_error(regulatory_actor):
regulatory_actor.working.remote = AsyncMock(side_effect=RuntimeError("ray fail"))
regulatory_actor.working = AsyncMock(side_effect=RuntimeError("ray fail"))
payload = {
"post_type": "message",
"message_type": "private",
@@ -153,7 +153,7 @@ async def test_dispatch_event_swallows_actor_error(regulatory_actor):
async def test_dispatch_event_returns_none_when_reply_empty(fake_actors):
inner = MagicMock()
inner.working.remote = AsyncMock(
inner.working = AsyncMock(
return_value=MessageResponse(
platform="onebot", platform_id="private:1", reply_message=""
)
+6 -6
View File
@@ -36,9 +36,9 @@ def app_alice():
def _register_pg(fake_actors, owner: str = "alice"):
pg = types.SimpleNamespace()
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=_make_workflow(owner)))
pg.get_workflow_context = types.SimpleNamespace(remote=AsyncMock(return_value=None))
pg.get_workflow_graph_state = types.SimpleNamespace(remote=AsyncMock(return_value=None))
pg.get_workflow = AsyncMock(return_value=_make_workflow(owner))
pg.get_workflow_context = AsyncMock(return_value=None)
pg.get_workflow_graph_state = AsyncMock(return_value=None)
fake_actors.register("postgres_database", pg)
return pg
@@ -54,7 +54,7 @@ async def test_detail_forbidden_other_user(app_alice, fake_actors):
@pytest.mark.asyncio
async def test_detail_not_found(app_alice, fake_actors):
pg = types.SimpleNamespace()
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
pg.get_workflow = AsyncMock(return_value=None)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
resp = await c.get("/api/v1/workflow/trace-nonexist")
@@ -80,7 +80,7 @@ async def test_resume_forbidden_other_user(app_alice, fake_actors):
@pytest.mark.asyncio
async def test_resume_not_found(app_alice, fake_actors):
pg = types.SimpleNamespace()
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
pg.get_workflow = AsyncMock(return_value=None)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
resp = await c.post("/api/v1/workflow/trace-nonexist/resume")
@@ -106,7 +106,7 @@ async def test_sse_forbidden_other_user(app_alice, fake_actors):
@pytest.mark.asyncio
async def test_sse_not_found(app_alice, fake_actors):
pg = types.SimpleNamespace()
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
pg.get_workflow = AsyncMock(return_value=None)
fake_actors.register("postgres_database", pg)
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
resp = await c.get("/api/v1/workflow/sse/trace-nonexist")
+38 -30
View File
@@ -16,7 +16,7 @@ from kilostar.core.global_state_machine import global_state_machine as gsm_modul
@pytest.fixture
def gsm_instance(monkeypatch):
GSMClass = gsm_module.GlobalStateMachine.__ray_actor_class__
GSMClass = gsm_module.GlobalStateMachine
obj = GSMClass.__new__(GSMClass)
obj._mcp_servers = {}
obj._tool_configs = {}
@@ -50,30 +50,33 @@ def gsm_instance(monkeypatch):
async def test_add_mcp_server(gsm_instance):
obj = gsm_instance
saved = {"server_id": "fs", "name": "fs", "transport": "stdio"}
obj.postgres_database.upsert_mcp_server.remote = AsyncMock(return_value=saved)
obj.postgres_database.upsert_mcp_server = AsyncMock(return_value=saved)
ok = await obj.add_mcp_server("fs", {"name": "fs", "transport": "stdio"})
assert ok is True
assert obj._mcp_servers["fs"] == saved
def test_get_mcp_server_configs_returns_copy(gsm_instance):
@pytest.mark.asyncio
async def test_get_mcp_server_configs_returns_copy(gsm_instance):
obj = gsm_instance
obj._mcp_servers["fs"] = {"name": "fs", "transport": "stdio"}
res1 = obj.get_mcp_server_configs()
res1 = await obj.get_mcp_server_configs()
res1["fs"] = {"mutated": True}
res2 = obj.get_mcp_server_configs()
res2 = await obj.get_mcp_server_configs()
assert res2["fs"]["name"] == "fs"
def test_get_mcp_server_returns_none_when_missing(gsm_instance):
assert gsm_instance.get_mcp_server("nope") is None
@pytest.mark.asyncio
async def test_get_mcp_server_returns_none_when_missing(gsm_instance):
assert await gsm_instance.get_mcp_server("nope") is None
def test_list_mcp_servers_includes_server_id(gsm_instance):
@pytest.mark.asyncio
async def test_list_mcp_servers_includes_server_id(gsm_instance):
obj = gsm_instance
obj._mcp_servers["fs"] = {"name": "fs", "transport": "stdio"}
listed = obj.list_mcp_servers()
listed = await obj.list_mcp_servers()
assert listed[0]["server_id"] == "fs"
assert listed[0]["name"] == "fs"
@@ -82,7 +85,7 @@ def test_list_mcp_servers_includes_server_id(gsm_instance):
async def test_delete_mcp_server(gsm_instance):
obj = gsm_instance
obj._mcp_servers["fs"] = {"name": "fs"}
obj.postgres_database.delete_mcp_server_db.remote = AsyncMock(return_value=True)
obj.postgres_database.delete_mcp_server_db = AsyncMock(return_value=True)
assert await obj.delete_mcp_server("fs") is True
assert "fs" not in obj._mcp_servers
@@ -91,7 +94,7 @@ async def test_delete_mcp_server(gsm_instance):
@pytest.mark.asyncio
async def test_delete_unknown_mcp_server(gsm_instance):
obj = gsm_instance
obj.postgres_database.delete_mcp_server_db.remote = AsyncMock(return_value=False)
obj.postgres_database.delete_mcp_server_db = AsyncMock(return_value=False)
assert await obj.delete_mcp_server("nope") is False
@@ -101,46 +104,49 @@ async def test_delete_unknown_mcp_server(gsm_instance):
@pytest.mark.asyncio
async def test_set_and_get_tool_config(gsm_instance):
obj = gsm_instance
obj.postgres_database.upsert_tool_config.remote = AsyncMock(
obj.postgres_database.upsert_tool_config = AsyncMock(
return_value={"tool_name": "tavily_search", "config": {"api_key": "xxx"}}
)
await obj.set_tool_config("tavily_search", {"api_key": "xxx"})
assert obj.get_tool_config("tavily_search") == {"api_key": "xxx"}
assert await obj.get_tool_config("tavily_search") == {"api_key": "xxx"}
def test_get_unknown_tool_config_returns_empty(gsm_instance):
assert gsm_instance.get_tool_config("not_exist") == {}
@pytest.mark.asyncio
async def test_get_unknown_tool_config_returns_empty(gsm_instance):
assert await gsm_instance.get_tool_config("not_exist") == {}
def test_get_tool_config_is_isolated_copy(gsm_instance):
@pytest.mark.asyncio
async def test_get_tool_config_is_isolated_copy(gsm_instance):
obj = gsm_instance
obj._tool_configs["tavily_search"] = {"api_key": "xxx"}
snapshot = obj.get_tool_config("tavily_search")
snapshot = await obj.get_tool_config("tavily_search")
snapshot["api_key"] = "changed"
assert obj.get_tool_config("tavily_search") == {"api_key": "xxx"}
assert await obj.get_tool_config("tavily_search") == {"api_key": "xxx"}
@pytest.mark.asyncio
async def test_delete_tool_config(gsm_instance):
obj = gsm_instance
obj._tool_configs["tavily_search"] = {"api_key": "xxx"}
obj.postgres_database.delete_tool_config_db.remote = AsyncMock(return_value=True)
obj.postgres_database.delete_tool_config_db = AsyncMock(return_value=True)
assert await obj.delete_tool_config("tavily_search") is True
assert obj.get_tool_config("tavily_search") == {}
assert await obj.get_tool_config("tavily_search") == {}
@pytest.mark.asyncio
async def test_delete_unknown_tool_config(gsm_instance):
obj = gsm_instance
obj.postgres_database.delete_tool_config_db.remote = AsyncMock(return_value=False)
obj.postgres_database.delete_tool_config_db = AsyncMock(return_value=False)
assert await obj.delete_tool_config("not_exist") is False
def test_list_tool_configs(gsm_instance):
@pytest.mark.asyncio
async def test_list_tool_configs(gsm_instance):
obj = gsm_instance
obj._tool_configs["tavily_search"] = {"api_key": "xxx"}
obj._tool_configs["notion"] = {"token": "yyy"}
raw = obj.list_tool_configs()
raw = await obj.list_tool_configs()
assert raw["tavily_search"] == {"api_key": "xxx"}
assert raw["notion"] == {"token": "yyy"}
@@ -152,7 +158,7 @@ def test_list_tool_configs(gsm_instance):
async def test_add_custom_toolset_success(gsm_instance):
obj = gsm_instance
saved = {"toolset_id": "t1", "name": "my-set", "tools": ["tp_a", "tp_b"]}
obj.postgres_database.upsert_custom_toolset.remote = AsyncMock(return_value=saved)
obj.postgres_database.upsert_custom_toolset = AsyncMock(return_value=saved)
result = await obj.add_custom_toolset(
toolset_id="t1", name="my-set", tools=["tp_a", "tp_b"]
@@ -171,24 +177,26 @@ async def test_add_custom_toolset_rejects_system_tools(gsm_instance):
)
def test_list_custom_toolsets(gsm_instance):
@pytest.mark.asyncio
async def test_list_custom_toolsets(gsm_instance):
obj = gsm_instance
obj._custom_toolsets["t1"] = {"toolset_id": "t1", "name": "a", "tools": []}
assert len(obj.list_custom_toolsets()) == 1
assert len(await obj.list_custom_toolsets()) == 1
def test_get_custom_toolset(gsm_instance):
@pytest.mark.asyncio
async def test_get_custom_toolset(gsm_instance):
obj = gsm_instance
obj._custom_toolsets["t1"] = {"toolset_id": "t1", "name": "a"}
assert obj.get_custom_toolset("t1")["name"] == "a"
assert obj.get_custom_toolset("nope") is None
assert (await obj.get_custom_toolset("t1"))["name"] == "a"
assert await obj.get_custom_toolset("nope") is None
@pytest.mark.asyncio
async def test_delete_custom_toolset(gsm_instance):
obj = gsm_instance
obj._custom_toolsets["t1"] = {"toolset_id": "t1"}
obj.postgres_database.delete_custom_toolset.remote = AsyncMock(return_value=True)
obj.postgres_database.delete_custom_toolset = AsyncMock(return_value=True)
assert await obj.delete_custom_toolset("t1") is True
assert "t1" not in obj._custom_toolsets
obj._global_tool_manager.rebuild_custom_toolsets.assert_called()
+37 -116
View File
@@ -1,26 +1,23 @@
"""GSM 配置快照(Object Store 读路径)相关测试。
"""GSM 配置快照(进程内读路径)相关测试。
去 Ray 后,快照不再进 Ray Object Store:``_publish_snapshot`` 直接在进程内构建
并持有一个不可变 ``GSMSnapshot`` 对象,``current_config_ref`` 返回 ``(version, snapshot)``。
读端 ``fetch_snapshot`` 用版本号做进程内缓存失效。
主要验证:
- ``GSMSnapshot`` 数据类可被 cloudpickle 序列化(ray.put 的隐式约束)
- ``_build_snapshot`` 正确从 6 类内存状态打包配置
- ``_publish_snapshot`` 让 version 单调递增并刷新 ObjectRef
- 写入路径(add_individual / set_tool_config / 等)会自动发布新快照
- ``_publish_snapshot`` 让 version 单调递增并刷新快照对象
- 写入路径(add_individual / add_provider_wrap / 等)会自动发布新快照
- ``fetch_snapshot`` 客户端:版本号一致时走本地缓存,不一致时重拉
"""
from __future__ import annotations
import asyncio
import pickle
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
# cloudpickle 是 ray 的传递依赖,不直接列在 pyproject 里 —— 通过 ray._private 拿
from ray import cloudpickle
from kilostar.core.global_state_machine.gsm_snapshot import (
GSMSnapshot,
fetch_snapshot,
@@ -28,61 +25,14 @@ from kilostar.core.global_state_machine.gsm_snapshot import (
)
def test_empty_snapshot_can_cloudpickle_roundtrip():
"""空 snapshot 序列化反序列化语义不变(ray.put 的最低约束)。"""
snap = GSMSnapshot()
blob = cloudpickle.dumps(snap)
restored: GSMSnapshot = cloudpickle.loads(blob)
assert restored.version == 0
assert restored.providers == {}
assert restored.individuals == {}
def test_snapshot_with_real_data_roundtrip():
"""带真实 Provider + 函数引用 + dict 数据的 snapshot 也能 round-trip。"""
from kilostar.core.global_state_machine.model_provider.base_provider import (
Provider,
)
def _sample_tool(query: str) -> str:
return f"echo:{query}"
snap = GSMSnapshot(
version=42,
providers={
"p1": Provider(
provider_title="p1",
provider_url="http://x",
provider_apikey="sk-x",
provider_models=["gpt-4o"],
provider_type="openai",
),
},
individuals={"agent-a": {"agent_id": "agent-a", "model_id": "gpt-4o"}},
tool_funcs={"echo": _sample_tool},
)
blob = cloudpickle.dumps(snap)
restored: GSMSnapshot = cloudpickle.loads(blob)
assert restored.version == 42
assert restored.providers["p1"].provider_title == "p1"
assert restored.individuals["agent-a"]["model_id"] == "gpt-4o"
# 模块级函数 cloudpickle 后仍可调用
# 注意:此处函数是测试模块的局部,cloudpickle 会把字节码一并序列化
assert restored.tool_funcs["echo"]("hi") == "echo:hi"
# ─── GSM actor 集成(绕过 @ray.remote 直接构造) ────────────────────
# ─── GSM 集成(直接构造 plain 对象) ────────────────────────────────
@pytest.fixture
def gsm_instance(monkeypatch):
def gsm_instance():
from kilostar.core.global_state_machine.global_state_machine import (
GlobalStateMachine,
)
cls = GlobalStateMachine.__ray_actor_class__
obj = cls.__new__(cls)
# 手动还原 __init__ 副作用
from kilostar.core.global_state_machine.individual_manager import (
GlobalIndividualManager,
)
@@ -90,6 +40,7 @@ def gsm_instance(monkeypatch):
from kilostar.core.global_state_machine.skill_manager import GlobalSkillManager
from kilostar.core.global_state_machine.tool_manager import GlobalToolManager
obj = GlobalStateMachine.__new__(GlobalStateMachine)
obj._global_provider_manager = ProviderManager(postgres=None)
obj._global_tool_manager = GlobalToolManager()
obj._global_skill_manager = GlobalSkillManager()
@@ -100,18 +51,6 @@ def gsm_instance(monkeypatch):
obj._config_version = 0
obj._current_ref = None
obj.postgres_database = MagicMock()
# ray.put 在测试沙箱里因 psutil PID 检查失败,mock 成"返回一个 sentinel ref"
# 我们关心的是 _publish_snapshot 的语义流,不是真把对象塞进 plasma
import kilostar.core.global_state_machine.global_state_machine as gsm_mod
counter = {"n": 0}
def _fake_put(snapshot):
counter["n"] += 1
return f"fake-ref-{counter['n']}"
monkeypatch.setattr(gsm_mod.ray, "put", _fake_put)
return obj
@@ -145,7 +84,7 @@ def test_build_snapshot_picks_up_all_six_categories(gsm_instance):
def test_build_snapshot_exposes_system_tools_by_scope(gsm_instance):
"""系统工具按 scope 分桶的工具名清单要随快照发布出去(客户端重建 toolset 用)。"""
tm = gsm_instance._global_tool_manager
# 模拟 tool_manager 内部状态:default scope 有 file_reader,control_node 有 approval
def _f1():
return "f1"
@@ -159,7 +98,6 @@ def test_build_snapshot_exposes_system_tools_by_scope(gsm_instance):
snap = gsm_instance._build_snapshot()
assert snap.system_tools_by_scope.get("default") == ["file_reader"]
assert snap.system_tools_by_scope.get("control_node") == ["approval"]
# tool_funcs 拍平后两者都应存在
assert set(snap.tool_funcs.keys()) == {"file_reader", "approval"}
@@ -175,15 +113,15 @@ def test_publish_snapshot_increments_version(gsm_instance):
gsm_instance._publish_snapshot()
assert gsm_instance._config_version == 2
assert gsm_instance._current_ref is not ref1 # 新 put 应是新 ref
assert gsm_instance._current_ref is not ref1 # 重新构建应是新对象
@pytest.mark.asyncio
async def test_current_config_ref_lazy_publishes_when_empty(gsm_instance):
"""从未发布过快照时,current_config_ref 应自动发布一次而不是返回 None。"""
version, ref = await gsm_instance.current_config_ref()
version, snap = await gsm_instance.current_config_ref()
assert version == 1
assert ref is not None
assert snap is not None
@pytest.mark.asyncio
@@ -197,7 +135,7 @@ async def test_current_version_is_lightweight(gsm_instance):
async def test_add_individual_publishes_new_snapshot(gsm_instance):
"""写入路径 add_individual 应自动 +1 version。"""
before = gsm_instance._config_version
gsm_instance.add_individual("agent-x", {"model_id": "gpt-4o"})
await gsm_instance.add_individual("agent-x", {"model_id": "gpt-4o"})
after = gsm_instance._config_version
assert after == before + 1
@@ -220,8 +158,7 @@ async def test_add_provider_wrap_publishes_new_snapshot(gsm_instance):
gsm_instance._global_provider_manager.provider_mapper[
"openai"
].create_provider = AsyncMock(return_value=fake_provider)
gsm_instance.postgres_database.add_provider_db = MagicMock()
gsm_instance.postgres_database.add_provider_db.remote = AsyncMock()
gsm_instance.postgres_database.add_provider_db = AsyncMock()
before = gsm_instance._config_version
await gsm_instance.add_provider_wrap(
@@ -240,69 +177,53 @@ async def test_add_provider_wrap_publishes_new_snapshot(gsm_instance):
@pytest.mark.asyncio
async def test_fetch_snapshot_uses_local_cache_when_version_matches():
"""模拟 GSM actor,验证版本号一致时不走 ray.get。"""
"""版本号一致时不再调 current_config_ref,直接返回本地缓存快照。"""
reset_local_cache()
snap = GSMSnapshot(version=5, providers={"p": MagicMock()})
# mock GSM handle:第一次 fetch 全走,第二次只 current_version
fake_gsm = MagicMock()
fake_gsm.current_version = MagicMock()
fake_gsm.current_version.remote = AsyncMock(return_value=5)
fake_gsm.current_config_ref = MagicMock()
fake_gsm.current_version = AsyncMock(return_value=5)
fake_gsm.current_config_ref = AsyncMock(
side_effect=AssertionError("不应触发:缓存版本一致时不应调 current_config_ref")
)
# 提前把缓存预热成 v5(模拟之前已经 fetch 过)
from kilostar.core.global_state_machine import gsm_snapshot as snap_mod
snap_mod._local_cache["version"] = 5
snap_mod._local_cache["snapshot"] = snap
# 不 mock current_config_ref —— 如果它被调用了,AttributeError 会让测试失败
fake_gsm.current_config_ref.remote = AsyncMock(
side_effect=AssertionError("不应触发:缓存版本一致时不应调 current_config_ref")
)
result = await fetch_snapshot(gsm_actor=fake_gsm)
assert result is snap
fake_gsm.current_version.remote.assert_awaited_once()
fake_gsm.current_version.assert_awaited_once()
@pytest.mark.asyncio
async def test_fetch_snapshot_refetches_when_version_changes(monkeypatch):
"""版本号变了应重新 ray.get 拉新 snapshot。"""
async def test_fetch_snapshot_refetches_when_version_changes():
"""版本号变了应重新拉取新 snapshot 并更新缓存。"""
reset_local_cache()
new_snap = GSMSnapshot(version=10)
fake_gsm = MagicMock()
fake_gsm.current_version = MagicMock()
fake_gsm.current_version.remote = AsyncMock(return_value=10)
fake_gsm.current_config_ref = MagicMock()
fake_gsm.current_config_ref.remote = AsyncMock(return_value=(10, "fake-ref"))
# mock ray.get 让它直接返回我们准备的 snap
import kilostar.core.global_state_machine.gsm_snapshot as snap_mod
monkeypatch.setattr(snap_mod.ray, "get", lambda ref: new_snap)
fake_gsm.current_version = AsyncMock(return_value=10)
fake_gsm.current_config_ref = AsyncMock(return_value=(10, new_snap))
result = await fetch_snapshot(gsm_actor=fake_gsm)
assert result is new_snap
fake_gsm.current_config_ref.remote.assert_awaited_once()
# 缓存应已更新到 v10
fake_gsm.current_config_ref.assert_awaited_once()
from kilostar.core.global_state_machine import gsm_snapshot as snap_mod
assert snap_mod._local_cache["version"] == 10
@pytest.mark.asyncio
async def test_fetch_snapshot_use_cache_false_skips_cache(monkeypatch):
async def test_fetch_snapshot_use_cache_false_skips_cache():
"""``use_cache=False`` 直接走 current_config_ref,不读本地缓存。"""
reset_local_cache()
fresh = GSMSnapshot(version=1)
fake_gsm = MagicMock()
fake_gsm.current_config_ref = MagicMock()
fake_gsm.current_config_ref.remote = AsyncMock(return_value=(1, "ref"))
import kilostar.core.global_state_machine.gsm_snapshot as snap_mod
monkeypatch.setattr(snap_mod.ray, "get", lambda ref: fresh)
fake_gsm.current_config_ref = AsyncMock(return_value=(1, fresh))
result = await fetch_snapshot(gsm_actor=fake_gsm, use_cache=False)
assert result is fresh
@@ -329,7 +250,10 @@ def test_build_tools_for_scope_assembles_system_and_custom():
snap = GSMSnapshot(
all_funcs={"sys_default": _sys_default, "sys_scope": _sys_scope, "tp_a": _tp_a},
custom_toolsets={
"system_basic": {"toolset_id": "system_basic", "tools": ["sys_default", "sys_scope"]},
"system_basic": {
"toolset_id": "system_basic",
"tools": ["sys_default", "sys_scope"],
},
"grp": {"toolset_id": "grp", "tools": ["tp_a"]},
},
)
@@ -345,8 +269,5 @@ def test_build_tools_for_scope_skips_empty_buckets():
build_tools_for_scope,
)
snap = GSMSnapshot(
all_funcs={},
custom_toolsets={},
)
snap = GSMSnapshot(all_funcs={}, custom_toolsets={})
assert build_tools_for_scope(snap, "control_node") == []
+2 -2
View File
@@ -20,7 +20,7 @@ def regulatory_instance():
from kilostar.core.individual.regulatory_node.regulatory_node import (
RegulatoryNode,
)
cls = RegulatoryNode.__ray_actor_class__
cls = RegulatoryNode
obj = cls.__new__(cls)
from kilostar.utils.logger import get_logger
obj.logger = get_logger("regulatory_node")
@@ -86,7 +86,7 @@ def consciousness_instance():
from kilostar.core.individual.consciousness_node.consciousness_node import (
ConsciousnessNode,
)
cls = ConsciousnessNode.__ray_actor_class__
cls = ConsciousnessNode
obj = cls.__new__(cls)
from kilostar.utils.logger import get_logger
obj.logger = get_logger("consciousness_node")
+1 -7
View File
@@ -141,17 +141,11 @@ async def test_on_first_install_default_is_noop():
async def test_install_marker_drives_on_first_install(tmp_path, monkeypatch):
"""首次装载触发 on_first_install + 写 marker;二次装载不再触发。"""
from kilostar.utils import settings as _settings_mod
from kilostar.utils import ray_compat
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
monkeypatch.setenv("KILOSTAR_PLUGIN_DIR", str(tmp_path))
_settings_mod.get_settings.cache_clear()
# 强制走 standalone 分支,重新 import plugin_manager 以 re-decorate
import importlib
import kilostar.plugin_runtime.plugin_manager as pm_mod
importlib.reload(pm_mod)
GlobalPluginManager = pm_mod.GlobalPluginManager
from kilostar.plugin_runtime.plugin_manager import GlobalPluginManager
plugin_dir = tmp_path / "demo_plug"
(plugin_dir / "core").mkdir(parents=True)
+6 -9
View File
@@ -30,8 +30,7 @@ async def test_add_provider_happy_path_writes_register_and_db():
pm.provider_mapper["openai"].create_provider = AsyncMock(return_value=fake_provider)
postgres = MagicMock()
postgres.add_provider_db = MagicMock()
postgres.add_provider_db.remote = AsyncMock(return_value=None)
postgres.add_provider_db = AsyncMock(return_value=None)
await pm.add_provider(
provider_type="openai",
@@ -44,8 +43,8 @@ async def test_add_provider_happy_path_writes_register_and_db():
assert "my-openai" in pm.provider_register
assert pm.provider_register["my-openai"] is fake_provider
postgres.add_provider_db.remote.assert_awaited_once()
kwargs = postgres.add_provider_db.remote.await_args.kwargs
postgres.add_provider_db.assert_awaited_once()
kwargs = postgres.add_provider_db.await_args.kwargs
assert kwargs["provider_title"] == "my-openai"
assert kwargs["provider_apikey"] == "sk-xxx"
assert kwargs["provider_models"] == ["gpt-4o"]
@@ -55,8 +54,7 @@ async def test_add_provider_happy_path_writes_register_and_db():
async def test_add_provider_unknown_type_returns_none(caplog):
pm = ProviderManager(postgres=None)
postgres = MagicMock()
postgres.add_provider_db = MagicMock()
postgres.add_provider_db.remote = AsyncMock()
postgres.add_provider_db = AsyncMock()
result = await pm.add_provider(
provider_type="not_supported",
@@ -69,7 +67,7 @@ async def test_add_provider_unknown_type_returns_none(caplog):
assert result is None
assert "x" not in pm.provider_register
postgres.add_provider_db.remote.assert_not_awaited()
postgres.add_provider_db.assert_not_awaited()
@pytest.mark.asyncio
@@ -85,8 +83,7 @@ async def test_add_provider_network_error_raises_retryable():
)
postgres = MagicMock()
postgres.add_provider_db = MagicMock()
postgres.add_provider_db.remote = AsyncMock()
postgres.add_provider_db = AsyncMock()
with pytest.raises(RetryableError):
await pm.add_provider(
-106
View File
@@ -1,106 +0,0 @@
"""ray_compat 适配层单元测试。
验证 StandaloneProxy / _MethodProxy / actor_class / remote_task
在单机模式下的行为是否正确模拟了 Ray Actor Handle 的 .remote() 接口。
"""
import asyncio
import pytest
from kilostar.utils import ray_compat
from kilostar.utils.ray_compat import StandaloneProxy, _MethodProxy
class TestMethodProxy:
def test_sync_method(self):
def add(a, b):
return a + b
proxy = _MethodProxy(add)
result = asyncio.get_event_loop().run_until_complete(proxy.remote(2, 3))
assert result == 5
def test_async_method(self):
async def async_add(a, b):
return a + b
proxy = _MethodProxy(async_add)
result = asyncio.get_event_loop().run_until_complete(proxy.remote(4, 6))
assert result == 10
class TestStandaloneProxy:
def test_method_call(self):
class FakeActor:
def greet(self, name):
return f"hello {name}"
proxy = StandaloneProxy(FakeActor())
future = proxy.greet.remote("world")
result = asyncio.get_event_loop().run_until_complete(future)
assert result == "hello world"
def test_async_method_call(self):
class FakeActor:
async def compute(self, x):
return x * 2
proxy = StandaloneProxy(FakeActor())
future = proxy.compute.remote(7)
result = asyncio.get_event_loop().run_until_complete(future)
assert result == 14
def test_attribute_access(self):
class FakeActor:
def __init__(self):
self.name = "test"
proxy = StandaloneProxy(FakeActor())
assert proxy.name == "test"
class TestActorClass:
def test_standalone_returns_class_unchanged(self, monkeypatch):
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
@ray_compat.actor_class
class MyActor:
def do_work(self):
return 42
instance = MyActor()
assert instance.do_work() == 42
def test_standalone_class_is_plain_python(self, monkeypatch):
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
@ray_compat.actor_class
class MyActor:
pass
assert not hasattr(MyActor, "remote")
assert not hasattr(MyActor, "options")
class TestRemoteTask:
def test_sync_task(self, monkeypatch):
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
@ray_compat.remote_task
def multiply(a, b):
return a * b
future = multiply.remote(3, 4)
result = asyncio.get_event_loop().run_until_complete(future)
assert result == 12
def test_async_task(self, monkeypatch):
monkeypatch.setattr(ray_compat, "_STANDALONE", True)
@ray_compat.remote_task
async def async_multiply(a, b):
return a * b
future = async_multiply.remote(5, 6)
result = asyncio.get_event_loop().run_until_complete(future)
assert result == 30
+1 -16
View File
@@ -4,7 +4,7 @@
- ``request_id`` / ``trace_id`` 默认空、bind 后可读、reset 后还原
- ``request_id_scope`` / ``trace_id_scope`` 上下文管理器
- ``snapshot`` / ``apply_snapshot`` 跨边界透传
- ``snapshot`` 快照当前上下文的 ID
- logger 切面:``contextvars`` 中的值会自动写入 ``record["extra"]``
"""
@@ -62,21 +62,6 @@ def test_snapshot_returns_current_ids():
assert snap == {"request_id": "r1", "trace_id": "t1"}
def test_apply_snapshot_restores_after_exit():
snap = {"request_id": "r2", "trace_id": "t2"}
with rc.apply_snapshot(snap):
assert rc.get_request_id() == "r2"
assert rc.get_trace_id() == "t2"
assert rc.get_request_id() == ""
assert rc.get_trace_id() == ""
def test_apply_snapshot_handles_none():
"""传 None 应是 no-op,不报错。"""
with rc.apply_snapshot(None):
assert rc.get_request_id() == ""
@pytest.mark.asyncio
async def test_contextvars_isolated_between_concurrent_tasks():
"""两个并发的 asyncio task 各自的 trace_id 不应互相串味。"""
+94
View File
@@ -0,0 +1,94 @@
"""进程内 actor 运行时(``kilostar.utils.actor``)单元测试。
覆盖:注册/寻址/注销;句柄对同步与异步方法都返回 awaitable;便捷 getter;
未注册抛 KeyError。
"""
from __future__ import annotations
import pytest
from kilostar.utils import actor as actor_mod
from kilostar.utils.actor import (
clear_actors,
get_actor,
get_gsm,
get_gwm,
get_postgres,
register_actor,
unregister_actor,
)
@pytest.fixture(autouse=True)
def _clean_registry():
clear_actors()
yield
clear_actors()
class _Sample:
def __init__(self) -> None:
self.value = 7
def sync_method(self, x):
return x + 1
async def async_method(self, x):
return x * 2
@pytest.mark.asyncio
async def test_sync_method_is_awaitable_through_handle():
register_actor("sample", _Sample())
handle = get_actor("sample")
assert await handle.sync_method(41) == 42
@pytest.mark.asyncio
async def test_async_method_forwards_through_handle():
register_actor("sample", _Sample())
handle = get_actor("sample")
assert await handle.async_method(21) == 42
def test_non_callable_attribute_passthrough():
register_actor("sample", _Sample())
assert get_actor("sample").value == 7
def test_get_unregistered_raises_keyerror():
with pytest.raises(KeyError):
get_actor("does_not_exist")
def test_unregister_then_missing():
register_actor("sample", _Sample())
assert get_actor("sample") is not None
unregister_actor("sample")
with pytest.raises(KeyError):
get_actor("sample")
# 幂等:再次注销不报错
unregister_actor("sample")
def test_convenience_getters_resolve_named_actors():
register_actor("postgres_database", _Sample())
register_actor("global_state_machine", _Sample())
register_actor("global_workflow_manager", _Sample())
assert get_postgres() is get_actor("postgres_database")
assert get_gsm() is get_actor("global_state_machine")
assert get_gwm() is get_actor("global_workflow_manager")
@pytest.mark.asyncio
async def test_reregister_overwrites_underlying_instance():
first = _Sample()
first.value = 100
register_actor("sample", first)
assert get_actor("sample").value == 100
second = _Sample()
second.value = 200
register_actor("sample", second)
assert get_actor("sample").value == 200
-44
View File
@@ -1,44 +0,0 @@
"""``utils.get_tool`` 在真实仓库目录上的加载行为。"""
from kilostar.utils import get_tool
from kilostar.utils.get_tool import (
_get_tool_func,
del_tool_cache,
load_tools_from_list,
)
def setup_function(_func):
"""每个测试前清空模块级缓存,避免相互影响。"""
get_tool._tool_cache.clear()
def test_load_existing_tool_via_load_tools_from_list():
tools = load_tools_from_list(["file_reader"])
assert len(tools) == 1
assert tools[0].__name__ == "file_reader"
def test_loader_caches_function():
f1 = _get_tool_func("file_reader")
f2 = _get_tool_func("file_reader")
assert f1 is f2
assert "file_reader" in get_tool._tool_cache
def test_del_tool_cache_removes_entry():
_get_tool_func("file_reader")
assert "file_reader" in get_tool._tool_cache
del_tool_cache("file_reader")
assert "file_reader" not in get_tool._tool_cache
def test_load_unknown_tool_returns_none_and_keeps_others():
tools = load_tools_from_list(["file_reader", "definitely_not_exist"])
assert len(tools) == 1
assert tools[0].__name__ == "file_reader"
def test_load_tools_from_list_handles_none_and_empty():
assert load_tools_from_list(None) == []
assert load_tools_from_list([]) == []
-107
View File
@@ -1,107 +0,0 @@
"""``ray_hook`` 中纯逻辑容器与 actor 句柄缓存的行为。"""
from unittest.mock import MagicMock
import pytest
from kilostar.utils.ray_hook import ActorList
def test_actor_list_attribute_set_get_delete():
actors = ActorList()
actors.foo = "bar"
assert actors.foo == "bar"
del actors.foo
with pytest.raises(AttributeError):
_ = actors.foo
def test_actor_list_missing_raises_attribute_error():
actors = ActorList()
with pytest.raises(AttributeError):
_ = actors.not_exist
def test_actor_list_delete_missing_raises_attribute_error():
actors = ActorList()
with pytest.raises(AttributeError):
del actors.not_exist
def test_ray_actor_hook_uses_fake_registry(fake_actors):
"""``ray_actor_hook`` 通过 fake registry 取 actor 并组装成 ActorList。"""
handle = MagicMock()
fake_actors.register("postgres_database", handle)
from kilostar.utils.ray_hook import ray_actor_hook
actors = ray_actor_hook("postgres_database")
assert actors.postgres_database is handle
def test_ray_actor_hook_unknown_actor_raises(fake_actors):
from kilostar.utils.ray_hook import ray_actor_hook
with pytest.raises(ValueError):
ray_actor_hook("does_not_exist")
def test_wait_for_actor_returns_immediately_when_ready(fake_actors):
"""actor 已就绪时 wait_for_actor 立刻返回,不进入轮询等待。"""
handle = MagicMock()
fake_actors.register("postgres_database", handle)
from kilostar.utils.ray_hook import wait_for_actor
got = wait_for_actor("postgres_database", timeout=5.0)
assert got is handle
def test_wait_for_actor_times_out_with_clear_error(fake_actors):
"""超时仍未就绪时抛 TimeoutError,并在 message 里带 actor 名。"""
from kilostar.utils.ray_hook import wait_for_actor
with pytest.raises(TimeoutError) as exc_info:
wait_for_actor("never_ready", timeout=0.2, interval=0.05)
assert "never_ready" in str(exc_info.value)
def test_wait_for_actor_succeeds_after_delayed_registration(fake_actors):
"""actor 在第 N 次轮询时才注册,wait_for_actor 应在它就绪后返回。"""
from kilostar.utils.ray_hook import wait_for_actor
handle = MagicMock()
calls = {"n": 0}
original_get = fake_actors.get
def delayed_get(name, namespace="kilostar"):
calls["n"] += 1
if calls["n"] >= 3:
return handle
raise ValueError("not ready yet")
fake_actors.get = delayed_get
try:
got = wait_for_actor("late_actor", timeout=2.0, interval=0.05)
assert got is handle
assert calls["n"] >= 3
finally:
fake_actors.get = original_get
def test_ray_actor_hook_with_timeout_waits(fake_actors):
"""ray_actor_hook(timeout>0) 会走 wait_for_actor 等待路径。"""
from kilostar.utils.ray_hook import ray_actor_hook
handle = MagicMock()
calls = {"n": 0}
def delayed_get(name, namespace="kilostar"):
calls["n"] += 1
if calls["n"] >= 2:
return handle
raise ValueError("not ready yet")
fake_actors.get = delayed_get
actors = ray_actor_hook("slow_actor", timeout=2.0, interval=0.05)
assert actors.slow_actor is handle
+29 -63
View File
@@ -1,14 +1,14 @@
"""``ConsciousnessNode.start_workflow_design`` fire workflow ray task 的提交逻辑。
"""``ConsciousnessNode.start_workflow_design`` fire workflow task 的提交逻辑。
历史上这里有一个常驻的 ``WorkflowRunningEngine`` actor 做中转,现已删除:
workflow 是一次性、有头有尾的执行,更适合直接以 ray task 形式触发。
本测试保证 ConsciousnessNode 在工作流生成后正确 fire ``run_workflow_task``,
workflow 是一次性、有头有尾的执行,直接以 ``asyncio.create_task`` 触发
``run_workflow_task``。本测试保证 ConsciousnessNode 在工作流生成后正确 fire,
并通过 ``put_pending`` 推送 SSE 进度(节点端写 pending → API 端 SSE 读 pending)。
"""
from __future__ import annotations
from types import SimpleNamespace
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -25,26 +25,15 @@ def consciousness_instance():
)
from kilostar.utils.logger import get_logger
cls = ConsciousnessNode.__ray_actor_class__
obj = cls.__new__(cls)
obj = ConsciousnessNode.__new__(ConsciousnessNode)
obj.logger = get_logger("consciousness_node")
obj.agent = None
return obj
class _FakeActorRef:
"""模拟 ``ray_actor_hook("name").<name>`` 的链式取属性返回值。"""
def __init__(self, target):
self._target = target
def __getattr__(self, item):
return getattr(self._target, item)
@pytest.mark.asyncio
async def test_start_workflow_design_fires_run_workflow_task(
consciousness_instance, monkeypatch
consciousness_instance, fake_actors, monkeypatch
):
"""快乐路径:working 返回 ForWorkflowEngine,应 fire run_workflow_task 且推送 pending。"""
from kilostar.core.individual.consciousness_node.template import (
@@ -62,40 +51,28 @@ async def test_start_workflow_design_fires_run_workflow_task(
)
postgres = MagicMock()
postgres.get_all_worker_individual = MagicMock()
postgres.get_all_worker_individual.remote = AsyncMock(return_value=[])
postgres.update_workflow_status = MagicMock()
postgres.update_workflow_status.remote = AsyncMock()
postgres.get_all_worker_individual = AsyncMock(return_value=[])
postgres.update_workflow_status = AsyncMock()
fake_actors.register("postgres_database", postgres)
pending_writes: list[tuple[str, str]] = []
gwm = MagicMock()
gwm.put_pending = MagicMock()
gwm.put_pending.remote = AsyncMock(
gwm.put_pending = AsyncMock(
side_effect=lambda tid, msg: pending_writes.append((tid, msg))
)
def _fake_hook(name):
if name == "postgres_database":
return SimpleNamespace(postgres_database=_FakeActorRef(postgres))
if name == "global_workflow_manager":
return SimpleNamespace(global_workflow_manager=_FakeActorRef(gwm))
raise KeyError(name)
import kilostar.core.individual.consciousness_node.consciousness_node as cmod
monkeypatch.setattr(cmod, "ray_actor_hook", _fake_hook)
fake_actors.register("global_workflow_manager", gwm)
captured: dict = {}
def _fake_task_remote(workflow_dict, trace_id):
async def _fake_task(workflow_dict, trace_id):
captured["workflow_dict"] = workflow_dict
captured["trace_id"] = trace_id
return MagicMock()
monkeypatch.setattr(engine_module.run_workflow_task, "remote", _fake_task_remote)
monkeypatch.setattr(engine_module, "run_workflow_task", _fake_task)
await consciousness_instance.start_workflow_design("trace-123", "do something")
# 让 create_task 调度的协程跑完
await asyncio.sleep(0)
assert captured["trace_id"] == "trace-123"
assert captured["workflow_dict"]["title"] == "t"
@@ -106,44 +83,33 @@ async def test_start_workflow_design_fires_run_workflow_task(
@pytest.mark.asyncio
async def test_start_workflow_design_failed_path_marks_failed(
consciousness_instance, monkeypatch
consciousness_instance, fake_actors, monkeypatch
):
"""working 返回 None / 不匹配类型时应推送失败提示并把 workflow 状态置为 failed。"""
consciousness_instance.working = AsyncMock(return_value=None)
postgres = MagicMock()
postgres.get_all_worker_individual = MagicMock()
postgres.get_all_worker_individual.remote = AsyncMock(return_value=[])
postgres.update_workflow_status = MagicMock()
postgres.update_workflow_status.remote = AsyncMock()
postgres.get_all_worker_individual = AsyncMock(return_value=[])
postgres.update_workflow_status = AsyncMock()
fake_actors.register("postgres_database", postgres)
pending_writes: list[tuple[str, str]] = []
gwm = MagicMock()
gwm.put_pending = MagicMock()
gwm.put_pending.remote = AsyncMock(
gwm.put_pending = AsyncMock(
side_effect=lambda tid, msg: pending_writes.append((tid, msg))
)
def _fake_hook(name):
if name == "postgres_database":
return SimpleNamespace(postgres_database=_FakeActorRef(postgres))
if name == "global_workflow_manager":
return SimpleNamespace(global_workflow_manager=_FakeActorRef(gwm))
raise KeyError(name)
import kilostar.core.individual.consciousness_node.consciousness_node as cmod
monkeypatch.setattr(cmod, "ray_actor_hook", _fake_hook)
fake_actors.register("global_workflow_manager", gwm)
fired: list = []
monkeypatch.setattr(
engine_module.run_workflow_task,
"remote",
lambda *a, **kw: fired.append((a, kw)),
)
async def _fake_task(*a, **kw):
fired.append((a, kw))
monkeypatch.setattr(engine_module, "run_workflow_task", _fake_task)
await consciousness_instance.start_workflow_design("trace-x", "cmd")
await asyncio.sleep(0)
assert fired == [] # 没有 fire ray task
postgres.update_workflow_status.remote.assert_awaited_with("trace-x", "failed")
assert fired == [] # 没有 fire workflow task
postgres.update_workflow_status.assert_awaited_with("trace-x", "failed")
assert any("生成失败" in msg for _, msg in pending_writes)