from __future__ import annotations import asyncio import sys import threading import types from unittest.mock import AsyncMock, MagicMock import pytest from app.runtime.capabilities.errors import CapabilityRuntimeClosedError from app.startup import agent_initializer @pytest.mark.anyio async def test_disabled_initializer_does_not_materialize_manager(monkeypatch) -> None: """功能关闭时启动阶段不得解析完整 Agent 模块。""" activate = AsyncMock(return_value=None) monkeypatch.setattr( agent_initializer, "activate_agent_service", activate, ) initializer = agent_initializer.AgentInitializer() assert await initializer.initialize() is True assert initializer._initialized is False activate.assert_awaited_once_with() @pytest.mark.anyio async def test_cleanup_without_initialized_manager_does_not_query(monkeypatch) -> None: """清理空状态只能关闭已持有资源,不能为清理而触发首次导入。""" activate = AsyncMock(side_effect=AssertionError("service activated")) monkeypatch.setattr(agent_initializer, "activate_agent_service", activate) await agent_initializer.AgentInitializer().cleanup() activate.assert_not_awaited() @pytest.mark.anyio async def test_failed_initialize_keeps_manager_for_shutdown_cleanup( monkeypatch, ) -> None: """初始化中途失败时仍须保留实际 manager,供应用关闭释放部分资源。""" manager = AsyncMock() manager.initialize.side_effect = RuntimeError("partial initialization") monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) monkeypatch.setattr(agent_initializer, "agent_manager", manager) initializer = agent_initializer.AgentInitializer() assert await initializer.initialize() is False await initializer.cleanup() manager.close.assert_awaited_once_with() assert initializer._manager is None @pytest.mark.anyio async def test_compat_stop_closes_injected_manager_without_building_runtime( monkeypatch, ) -> None: """显式注入对象由兼容路径关闭,不为其构建空 Capability Runtime。""" events = [] manager = AsyncMock() manager.initialize.side_effect = lambda: events.append("initialize") manager.close.side_effect = lambda: events.append("close") monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True) monkeypatch.setattr(agent_initializer, "agent_manager", manager) shutdown = AsyncMock(side_effect=lambda: events.append("shutdown_gate")) monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", shutdown) monkeypatch.setattr( agent_initializer, "agent_initializer", agent_initializer.AgentInitializer(), ) assert await agent_initializer.init_agent() is True await agent_initializer.stop_agent() assert events == ["initialize", "close"] manager.initialize.assert_awaited_once_with() manager.close.assert_awaited_once_with() shutdown.assert_not_awaited() @pytest.mark.anyio async def test_production_initializer_delegates_lifecycle_to_runtime( monkeypatch, ) -> None: """生产路径只协调 service,不得再次手工 initialize 或 close canonical manager。""" manager = AsyncMock() activate = AsyncMock(return_value=manager) monkeypatch.setattr(agent_initializer, "agent_manager", None) monkeypatch.setattr(agent_initializer, "activate_agent_service", activate) initializer = agent_initializer.AgentInitializer() assert await initializer.initialize() is True await initializer.cleanup() activate.assert_awaited_once_with() manager.initialize.assert_not_awaited() manager.close.assert_not_awaited() assert initializer._manager is None @pytest.mark.anyio async def test_production_stop_seals_runtime_without_manually_closing_manager( monkeypatch, ) -> None: """生产关闭由 Runtime 关闸并释放 service,initializer 只清理自身引用。""" manager = AsyncMock() initializer = agent_initializer.AgentInitializer() initializer._manager = manager initializer._initialized = True initializer._compat_injected = False shutdown = AsyncMock() monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", shutdown) monkeypatch.setattr(agent_initializer, "agent_initializer", initializer) monkeypatch.setattr( agent_initializer, "is_tool_factory_materialized", lambda: False, ) await agent_initializer.stop_agent() shutdown.assert_awaited_once_with() manager.close.assert_not_awaited() assert initializer._manager is None @pytest.mark.anyio async def test_config_listener_delegates_watch_filter_to_runtime(monkeypatch) -> None: """配置监听器只转交 changed keys,不维护第二份启用开关。""" manager = AsyncMock() reconcile = AsyncMock(return_value=manager) monkeypatch.setattr(agent_initializer, "reconcile_agent_service", reconcile) initializer = agent_initializer.AgentInitializer() event = agent_initializer.Event( agent_initializer.EventType.ConfigChanged, {"key": {"AI_AGENT_ENABLE"}}, ) await initializer.handle_config_changed(event) reconcile.assert_awaited_once_with( reason="agent_service_config_changed", changed_keys={"AI_AGENT_ENABLE"}, retry=True, ) assert initializer._manager is manager assert initializer._initialized is True def test_config_listener_registration_is_idempotent_and_instance_free() -> None: """重复构造 initializer 不得累积监听器或持有过期实例。""" subscribers = getattr( agent_initializer.eventmanager, "_EventManager__broadcast_subscribers", ) AgentInitializer = agent_initializer.AgentInitializer AgentInitializer() AgentInitializer() listeners = tuple( subscribers.get(agent_initializer.EventType.ConfigChanged, {}).values() ) matching = [ listener for listener in listeners if listener is agent_initializer._handle_agent_config_changed ] assert len(matching) == 1 assert agent_initializer._handle_agent_config_changed.__closure__ is None @pytest.mark.anyio async def test_config_event_after_shutdown_is_fail_closed(monkeypatch) -> None: """关闭后的配置事件不得把 service 重新标为初始化成功。""" reconcile = AsyncMock(side_effect=CapabilityRuntimeClosedError("closed")) monkeypatch.setattr(agent_initializer, "reconcile_agent_service", reconcile) initializer = agent_initializer.AgentInitializer() initializer._shutdown_complete = True event = agent_initializer.Event( agent_initializer.EventType.ConfigChanged, {"key": "AI_AGENT_ENABLE"}, ) await initializer.handle_config_changed(event) reconcile.assert_not_awaited() assert initializer._manager is None assert initializer._initialized is False @pytest.mark.anyio async def test_stop_skips_tool_executor_cleanup_when_factory_is_unresolved( monkeypatch, ) -> None: """工具能力从未解析时,关闭路径不得为线程池清理导入工具基础模块。""" fake_base = types.ModuleType("app.agent.tools.base") cleanup = MagicMock() fake_base.shutdown_blocking_executors = cleanup monkeypatch.setitem(sys.modules, "app.agent.tools.base", fake_base) monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", AsyncMock()) monkeypatch.setattr( agent_initializer, "is_tool_factory_materialized", lambda: False, ) monkeypatch.setattr( agent_initializer, "agent_initializer", agent_initializer.AgentInitializer(), ) await agent_initializer.stop_agent() cleanup.assert_not_called() @pytest.mark.anyio async def test_stop_closes_tool_executor_after_factory_materialization( monkeypatch, ) -> None: """工具能力已解析时,应取消仍排队的阻塞工具任务。""" fake_base = types.ModuleType("app.agent.tools.base") cleanup = MagicMock() fake_base.shutdown_blocking_executors = cleanup monkeypatch.setitem(sys.modules, "app.agent.tools.base", fake_base) monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", AsyncMock()) monkeypatch.setattr( agent_initializer, "is_tool_factory_materialized", lambda: True, ) monkeypatch.setattr( agent_initializer, "agent_initializer", agent_initializer.AgentInitializer(), ) await agent_initializer.stop_agent() cleanup.assert_called_once_with(wait=False, cancel_futures=True) @pytest.mark.anyio async def test_stop_does_not_wait_for_running_blocking_tool(monkeypatch) -> None: """应用关闭不得等待已经进入线程池且尚未返回的工具调用。""" from app.agent.tools.base import MoviePilotTool started = threading.Event() release = threading.Event() def _blocking_call() -> str: started.set() release.wait() return "done" worker = asyncio.create_task( MoviePilotTool.run_blocking("web", _blocking_call) ) assert await asyncio.wait_for(asyncio.to_thread(started.wait), timeout=1) monkeypatch.setattr(agent_initializer, "begin_agent_shutdown", AsyncMock()) monkeypatch.setattr( agent_initializer, "is_tool_factory_materialized", lambda: True, ) monkeypatch.setattr( agent_initializer, "agent_initializer", agent_initializer.AgentInitializer(), ) try: await asyncio.wait_for(agent_initializer.stop_agent(), timeout=0.2) assert worker.done() is False finally: release.set() assert await asyncio.wait_for(worker, timeout=1) == "done"