Files
MoviePilot/tests/test_agent_lifecycle.py
T

334 lines
11 KiB
Python

import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.agent.orchestrator as agent_module
from app.agent import AgentManager
from app.agent.orchestrator import AgentManagerUnavailableError
from app.agent.memory import MemoryManager
from app.startup import agent_initializer, modules_initializer
@pytest.mark.anyio
async def test_agent_entrypoint_initializes_on_calling_loop(monkeypatch) -> None:
"""Agent 启动入口必须在应用主循环完成初始化。"""
current_loop = asyncio.get_running_loop()
initialized_loops = []
manager = AsyncMock()
async def initialize() -> None:
initialized_loops.append(asyncio.get_running_loop())
manager.initialize.side_effect = initialize
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(
agent_initializer,
"agent_initializer",
agent_initializer.AgentInitializer(),
)
assert await agent_initializer.init_agent() is True
assert initialized_loops == [current_loop]
manager.initialize.assert_awaited_once_with()
@pytest.mark.anyio
async def test_agent_manager_background_tasks_share_owner_loop(monkeypatch) -> None:
"""长期清理任务必须在同一循环创建、复用并完成关闭。"""
manager = AgentManager()
memory_manager = MemoryManager()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
current_loop = asyncio.get_running_loop()
await manager.initialize()
idle_cleanup_task = manager._idle_cleanup_task
memory_cleanup_task = memory_manager.cleanup_task
assert idle_cleanup_task is not None
assert memory_cleanup_task is not None
assert idle_cleanup_task.get_loop() is current_loop
assert memory_cleanup_task.get_loop() is current_loop
assert not idle_cleanup_task.done()
assert not memory_cleanup_task.done()
await manager.initialize()
assert manager._idle_cleanup_task is idle_cleanup_task
assert memory_manager.cleanup_task is memory_cleanup_task
await manager.close()
await manager.close()
assert manager._idle_cleanup_task is None
assert memory_manager.cleanup_task is None
assert idle_cleanup_task.done()
assert memory_cleanup_task.done()
@pytest.mark.anyio
async def test_agent_entrypoint_reuses_tasks_and_closes_idempotently(
monkeypatch,
) -> None:
"""全局启停入口重复调用时必须复用任务并安全收口。"""
manager = AgentManager()
memory_manager = MemoryManager()
initializer = agent_initializer.AgentInitializer()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(agent_initializer, "agent_initializer", initializer)
assert await agent_initializer.init_agent() is True
idle_cleanup_task = manager._idle_cleanup_task
memory_cleanup_task = memory_manager.cleanup_task
assert await agent_initializer.init_agent() is True
assert manager._idle_cleanup_task is idle_cleanup_task
assert memory_manager.cleanup_task is memory_cleanup_task
await agent_initializer.stop_agent()
await agent_initializer.stop_agent()
assert initializer._initialized is False
assert manager._idle_cleanup_task is None
assert memory_manager.cleanup_task is None
@pytest.mark.anyio
async def test_agent_initialization_failure_does_not_stop_module_startup(
monkeypatch,
) -> None:
"""Agent 初始化异常只关闭该能力,基础模块仍继续完成启动。"""
manager = AsyncMock()
manager.initialize.side_effect = RuntimeError("agent init failed")
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", True)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(
agent_initializer,
"agent_initializer",
agent_initializer.AgentInitializer(),
)
monkeypatch.setattr(modules_initializer, "init_agent", agent_initializer.init_agent)
for name in (
"DohHelper",
"SitesHelper",
"ResourceHelper",
"ModuleManager",
):
monkeypatch.setattr(modules_initializer, name, MagicMock())
monkeypatch.setattr(modules_initializer, "init_managed_resources", MagicMock())
monkeypatch.setattr(modules_initializer, "user_auth", MagicMock())
monkeypatch.setattr(modules_initializer.EventManager, "start", MagicMock())
for name in (
"init_plugin_report",
"init_subscribe_report",
"get_user_uuid",
"get_github_user",
):
monkeypatch.setattr(
modules_initializer.MoviePilotServerHelper,
name,
MagicMock(),
)
start_frontend = MagicMock()
check_auth = MagicMock()
monkeypatch.setattr(modules_initializer, "start_frontend", start_frontend)
monkeypatch.setattr(modules_initializer, "check_auth", check_auth)
await modules_initializer.init_modules()
manager.initialize.assert_awaited_once_with()
start_frontend.assert_called_once_with()
check_auth.assert_called_once_with()
@pytest.mark.anyio
async def test_disabled_agent_does_not_create_background_tasks(monkeypatch) -> None:
"""Agent 未启用时启动入口不得创建运行时任务。"""
manager = AsyncMock()
monkeypatch.setattr(agent_initializer.settings, "AI_AGENT_ENABLE", False)
monkeypatch.setattr(agent_initializer, "agent_manager", manager)
monkeypatch.setattr(
agent_initializer,
"agent_initializer",
agent_initializer.AgentInitializer(),
)
assert await agent_initializer.init_agent() is True
manager.initialize.assert_not_awaited()
@pytest.mark.anyio
async def test_agent_manager_acceptance_gate_rejects_stale_references(
monkeypatch,
) -> None:
"""未启动和关闭后的 manager 引用不得创建队列、worker 或 Agent。"""
manager = AgentManager()
memory_manager = MemoryManager()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
with pytest.raises(AgentManagerUnavailableError):
await manager.process_message("before-init", "1", "hello")
await manager.initialize()
manager._process_message_internal = AsyncMock(return_value="accepted")
assert await manager.process_message(
"running",
"1",
"hello",
wait_for_completion=True,
) == "accepted"
await manager.close()
with pytest.raises(AgentManagerUnavailableError):
await manager.process_message("after-close", "1", "hello")
assert manager._session_queues == {}
assert manager._session_workers == {}
assert manager.active_agents == {}
@pytest.mark.anyio
async def test_agent_manager_close_serializes_racing_enqueue_and_clear(
monkeypatch,
) -> None:
"""关闭、临时会话清理和迟到请求必须串行收口且只清理一次。"""
manager = AgentManager()
memory_manager = MemoryManager()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
started = asyncio.Event()
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
created = []
cleanup_calls = []
class BlockingAgent:
"""用于放大 close 与请求级 clear 竞态窗口。"""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
created.append(self)
async def process(self, _message, **_kwargs):
started.set()
await asyncio.Event().wait()
async def cleanup(self):
cleanup_calls.append(self)
cleanup_started.set()
await release_cleanup.wait()
await manager.initialize()
waiter = asyncio.create_task(
manager.process_message(
"closing",
"1",
"hello",
agent_factory=BlockingAgent,
wait_for_completion=True,
)
)
await asyncio.wait_for(started.wait(), timeout=1)
close_task = asyncio.create_task(manager.close())
await asyncio.wait_for(cleanup_started.wait(), timeout=1)
late_enqueue = asyncio.create_task(
manager.process_message("late", "1", "hello")
)
request_clear = asyncio.create_task(manager.clear_session("closing", "1"))
await asyncio.sleep(0)
assert not late_enqueue.done()
assert not request_clear.done()
release_cleanup.set()
await asyncio.wait_for(close_task, timeout=1)
with pytest.raises(AgentManagerUnavailableError):
await late_enqueue
await request_clear
with pytest.raises(AgentManagerUnavailableError):
await waiter
assert len(created) == 1
assert cleanup_calls == created
assert manager._session_queues == {}
assert manager._session_workers == {}
assert manager.active_agents == {}
@pytest.mark.anyio
async def test_clear_session_settles_current_and_queued_waiters(monkeypatch) -> None:
"""清空会话必须同时结束正在执行和尚未执行的等待请求。"""
manager = AgentManager()
memory_manager = MemoryManager()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
started = asyncio.Event()
async def block_current(_task):
started.set()
await asyncio.Event().wait()
manager._process_message_internal = block_current
await manager.initialize()
current_waiter = asyncio.create_task(
manager.process_message(
"session-with-queue",
"1",
"current",
wait_for_completion=True,
)
)
await asyncio.wait_for(started.wait(), timeout=1)
queued_waiter = asyncio.create_task(
manager.process_message(
"session-with-queue",
"1",
"queued",
wait_for_completion=True,
)
)
await asyncio.sleep(0)
await asyncio.wait_for(
manager.clear_session("session-with-queue", "1"),
timeout=1,
)
with pytest.raises(asyncio.CancelledError):
await current_waiter
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(queued_waiter, timeout=1)
assert "session-with-queue" not in manager._session_queues
assert "session-with-queue" not in manager._session_workers
await manager.close()
@pytest.mark.anyio
async def test_background_prompt_is_owned_and_cancelled_by_manager_close(
monkeypatch,
) -> None:
"""后台 prompt 必须进入 manager worker,关闭时同步结束且不残留临时会话。"""
manager = AgentManager()
memory_manager = MemoryManager()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
started = asyncio.Event()
async def block_background(task):
assert task.session_id.startswith("__managed_background_")
started.set()
await asyncio.Event().wait()
manager._process_message_internal = block_background
await manager.initialize()
execution = asyncio.create_task(
manager.run_background_prompt(
"background",
session_prefix="__managed_background",
)
)
await asyncio.wait_for(started.wait(), timeout=1)
assert len(manager._session_workers) == 1
await manager.close()
with pytest.raises(AgentManagerUnavailableError):
await execution
assert manager._session_queues == {}
assert manager._session_workers == {}
assert manager.active_agents == {}