"""GSM 配置快照(进程内读路径)相关测试。 去 Ray 后,快照不再进 Ray Object Store:``_publish_snapshot`` 直接在进程内构建 并持有一个不可变 ``GSMSnapshot`` 对象,``current_config_ref`` 返回 ``(version, snapshot)``。 读端 ``fetch_snapshot`` 用版本号做进程内缓存失效。 主要验证: - ``_build_snapshot`` 正确从 6 类内存状态打包配置 - ``_publish_snapshot`` 让 version 单调递增并刷新快照对象 - 写入路径(add_individual / add_provider_wrap / 等)会自动发布新快照 - ``fetch_snapshot`` 客户端:版本号一致时走本地缓存,不一致时重拉 """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest from kilostar.core.global_state_machine.gsm_snapshot import ( GSMSnapshot, fetch_snapshot, reset_local_cache, ) # ─── GSM 集成(直接构造 plain 对象) ──────────────────────────────── @pytest.fixture def gsm_instance(): from kilostar.core.global_state_machine.global_state_machine import ( GlobalStateMachine, ) from kilostar.core.global_state_machine.individual_manager import ( GlobalIndividualManager, ) from kilostar.core.global_state_machine.provider_manager import ProviderManager 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() obj._global_individual_manager = GlobalIndividualManager() obj._mcp_servers = {} obj._tool_configs = {} obj._custom_toolsets = {} obj._config_version = 0 obj._current_ref = None obj.postgres_database = MagicMock() return obj def test_build_snapshot_picks_up_all_six_categories(gsm_instance): """_build_snapshot 应正确从 GSM 内存的 6 类数据打包。""" from kilostar.core.global_state_machine.model_provider.base_provider import ( Provider, ) gsm_instance._global_provider_manager.provider_register["p1"] = Provider( provider_title="p1", provider_url="http://x", provider_apikey="k", provider_models=[], provider_type="openai", ) gsm_instance._global_individual_manager._individuals["a1"] = {"agent_id": "a1"} gsm_instance._mcp_servers["s1"] = {"server_id": "s1"} gsm_instance._tool_configs["t1"] = {"key": "v"} gsm_instance._custom_toolsets["ts1"] = {"toolset_id": "ts1"} snap = gsm_instance._build_snapshot() assert "p1" in snap.providers assert "a1" in snap.individuals assert "s1" in snap.mcp_servers assert "t1" in snap.tool_configs assert "ts1" in snap.custom_toolsets def test_build_snapshot_exposes_system_tools_by_scope(gsm_instance): """系统工具按 scope 分桶的工具名清单要随快照发布出去(客户端重建 toolset 用)。""" tm = gsm_instance._global_tool_manager def _f1(): return "f1" def _f2(): return "f2" tm._tool_funcs.clear() tm._tool_funcs["default"]["file_reader"] = _f1 tm._tool_funcs["control_node"]["approval"] = _f2 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"] assert set(snap.tool_funcs.keys()) == {"file_reader", "approval"} def test_publish_snapshot_increments_version(gsm_instance): assert gsm_instance._config_version == 0 assert gsm_instance._current_ref is None gsm_instance._publish_snapshot() v1 = gsm_instance._config_version ref1 = gsm_instance._current_ref assert v1 == 1 assert ref1 is not None gsm_instance._publish_snapshot() assert gsm_instance._config_version == 2 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, snap = await gsm_instance.current_config_ref() assert version == 1 assert snap is not None @pytest.mark.asyncio async def test_current_version_is_lightweight(gsm_instance): gsm_instance._publish_snapshot() gsm_instance._publish_snapshot() assert await gsm_instance.current_version() == 2 @pytest.mark.asyncio async def test_add_individual_publishes_new_snapshot(gsm_instance): """写入路径 add_individual 应自动 +1 version。""" before = gsm_instance._config_version await gsm_instance.add_individual("agent-x", {"model_id": "gpt-4o"}) after = gsm_instance._config_version assert after == before + 1 @pytest.mark.asyncio async def test_add_provider_wrap_publishes_new_snapshot(gsm_instance): """add_provider_wrap 即便走 mock 适配器也应该最终发布一次新快照。""" from kilostar.core.global_state_machine.model_provider.base_provider import ( Provider, ) fake_provider = Provider( provider_title="my-openai", provider_url="http://x", provider_apikey="k", provider_models=[], provider_type="openai", ) gsm_instance._global_provider_manager.provider_mapper["openai"] = MagicMock() gsm_instance._global_provider_manager.provider_mapper[ "openai" ].create_provider = AsyncMock(return_value=fake_provider) gsm_instance.postgres_database.add_provider_db = AsyncMock() before = gsm_instance._config_version await gsm_instance.add_provider_wrap( provider_type="openai", provider_title="my-openai", provider_url="http://x", provider_apikey="k", provider_owner="alice", ) after = gsm_instance._config_version assert after == before + 1 # ─── fetch_snapshot 客户端缓存 ──────────────────────────────────── @pytest.mark.asyncio async def test_fetch_snapshot_uses_local_cache_when_version_matches(): """版本号一致时不再调 current_config_ref,直接返回本地缓存快照。""" reset_local_cache() snap = GSMSnapshot(version=5, providers={"p": MagicMock()}) fake_gsm = MagicMock() fake_gsm.current_version = AsyncMock(return_value=5) fake_gsm.current_config_ref = AsyncMock( side_effect=AssertionError("不应触发:缓存版本一致时不应调 current_config_ref") ) from kilostar.core.global_state_machine import gsm_snapshot as snap_mod snap_mod._local_cache["version"] = 5 snap_mod._local_cache["snapshot"] = snap result = await fetch_snapshot(gsm_actor=fake_gsm) assert result is snap fake_gsm.current_version.assert_awaited_once() @pytest.mark.asyncio 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 = 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.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(): """``use_cache=False`` 直接走 current_config_ref,不读本地缓存。""" reset_local_cache() fresh = GSMSnapshot(version=1) fake_gsm = MagicMock() 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 # ─── build_tools_for_scope 客户端 helper ──────────────────────── def test_build_tools_for_scope_assembles_system_and_custom(): """客户端按 snapshot 的 custom_toolsets + all_funcs 展开为扁平 callable 列表。""" from kilostar.core.global_state_machine.gsm_snapshot import ( build_tools_for_scope, ) def _sys_default(): return "d" def _sys_scope(): return "s" def _tp_a(): return "a" 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"], }, "grp": {"toolset_id": "grp", "tools": ["tp_a"]}, }, ) result = build_tools_for_scope(snap, "control_node") assert len(result) == 3 assert result == [_sys_default, _sys_scope, _tp_a] def test_build_tools_for_scope_skips_empty_buckets(): """没有工具的 scope 返回空列表。""" from kilostar.core.global_state_machine.gsm_snapshot import ( build_tools_for_scope, ) snap = GSMSnapshot(all_funcs={}, custom_toolsets={}) assert build_tools_for_scope(snap, "control_node") == []