"""Agent 工具与 LLM 入口的延迟加载合同测试。""" from __future__ import annotations import json import subprocess import sys import threading import time from types import SimpleNamespace from unittest.mock import patch def _run_isolated(script: str) -> dict: """在全新解释器中执行导入探针,避免当前 pytest 模块缓存干扰。""" completed = subprocess.run( [sys.executable, "-c", script], check=True, capture_output=True, text=True, ) return json.loads(completed.stdout) def test_mcp_router_import_keeps_agent_tool_runtime_cold() -> None: """默认 API 路由加载不得提前物化工具目录或 Agent 编排。""" result = _run_isolated( """ import json import sys import app.api.endpoints.mcp forbidden = ( "app.agent.callback", "app.agent.orchestrator", "app.agent.tools.base", "app.agent.tools.catalog", "app.agent.tools.factory", "app.agent.tools.impl", "anthropic", "boto3", "google.genai", "langchain", "langgraph", "openai", ) loaded = sorted( name for name in sys.modules if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) ) print(json.dumps({"loaded": loaded})) """ ) assert result == {"loaded": []} def test_agent_initializer_import_only_registers_lazy_providers() -> None: """组合根导入只注册 provider,不得提前加载 Agent 重量实现。""" result = _run_isolated( """ import json import sys import app.startup.initializers.agent forbidden = ( "app.agent.orchestrator", "app.agent.llm.capability", "app.agent.llm.helper", "app.agent.llm.provider", "app.agent.prompt", "app.agent.tools.base", "app.agent.tools.factory", "app.agent.tools.impl", "anthropic", "langchain", "langgraph", "openai", ) loaded = sorted( name for name in sys.modules if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) ) print(json.dumps({"loaded": loaded})) """ ) assert result == {"loaded": []} def test_manager_constructor_and_llm_facade_are_lightweight() -> None: """构造全局 manager 与导入 LLM facade 都不加载真实目录或 provider。""" result = _run_isolated( """ import json import sys from app.agent.tools.manager import MoviePilotToolsManager import app.agent.llm manager = MoviePilotToolsManager(session_id="lazy", user_id="api") forbidden = ( "app.agent.llm.capability", "app.agent.llm.helper", "app.agent.llm.provider", "app.agent.tools.base", "app.agent.tools.catalog", "app.agent.tools.factory", "app.agent.tools.impl", "anthropic", "boto3", "google.genai", "langchain", "langchain_core", "openai", ) loaded = sorted( name for name in sys.modules if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) ) print(json.dumps({ "loaded": loaded, "tools": manager.tools, "catalog": manager.catalog, })) """ ) assert result == {"loaded": [], "tools": [], "catalog": None} def test_tool_catalog_materialization_does_not_load_streaming_callback() -> None: """工具目录和 schema 首用不应加载仅在真实编排中需要的回调实现。""" result = _run_isolated( """ import json import sys from typing import get_args, get_type_hints from app.testing.bootstrap import ensure_sites_stub ensure_sites_stub() from app.agent.runtime_loader import get_tool_factory factory = get_tool_factory() catalog = factory.create_catalog(session_id="lazy", user_id="api") from app.agent.tools.base import MoviePilotTool hints = get_type_hints(MoviePilotTool.set_stream_handler) handler_args = get_args(hints["stream_handler"]) print(json.dumps({ "callback_loaded": "app.agent.callback" in sys.modules, "catalog_entries": len(catalog.entries), "handler_types": [item.__name__ for item in handler_args], })) """ ) assert result["callback_loaded"] is False assert result["catalog_entries"] > 0 assert result["handler_types"] == ["_StreamingHandlerProtocol", "NoneType"] def test_legacy_streaming_handler_import_keeps_canonical_identity() -> None: """历史显式与星号导入必须按需返回真实 callback 类。""" result = _run_isolated( """ import json import sys import app.agent.tools.base as base cold_before_explicit = "app.agent.callback" not in sys.modules from app.agent.tools.base import StreamingHandler from app.agent.callback import StreamingHandler as CanonicalStreamingHandler namespace = {} exec("from app.agent.tools.base import *", namespace) print(json.dumps({ "cold_before_explicit": cold_before_explicit, "explicit_identity": StreamingHandler is CanonicalStreamingHandler, "star_identity": namespace["StreamingHandler"] is CanonicalStreamingHandler, })) """ ) assert result == { "cold_before_explicit": True, "explicit_identity": True, "star_identity": True, } def test_manager_first_catalog_use_is_single_flight(monkeypatch) -> None: """并发首次查询只能在 manager 锁内建立一次会话工具快照。""" from app.agent import runtime_loader from app.agent.tools.manager import MoviePilotToolsManager calls: list[tuple[str, str]] = [] fake_tool = SimpleNamespace( name="demo", description="demo tool", args_schema=None, _require_admin=False, ) class _Factory: """记录目录构造次数的轻量工厂替身。""" @classmethod def create_catalog(cls, **kwargs): calls.append((kwargs["session_id"], kwargs["user_id"])) time.sleep(0.05) return SimpleNamespace(tools=[fake_tool], plugin_revision=0) monkeypatch.setattr(runtime_loader, "get_tool_factory", lambda: _Factory) manager = MoviePilotToolsManager(session_id="session", user_id="user") results: list[list[str]] = [] def _list_tools() -> None: results.append([tool.name for tool in manager.list_tools()]) threads = [threading.Thread(target=_list_tools) for _ in range(2)] for thread in threads: thread.start() for thread in threads: thread.join() assert calls == [("session", "user")] assert results == [["demo"], ["demo"]] assert manager.tools == [fake_tool] assert manager.catalog is not None def test_legacy_explicit_tool_refresh_keeps_atomic_catalog_contract( monkeypatch, ) -> None: """插件显式刷新旧入口应继续发布同一次构造的完整目录快照。""" from app.agent import runtime_loader from app.agent.tools.manager import MoviePilotToolsManager calls: list[int] = [] fake_tool = SimpleNamespace(name="plugin_tool") fake_catalog = SimpleNamespace( tools=[fake_tool], plugin_revision=9, ) class _Factory: """提供固定 revision 快照的轻量工厂替身。""" @classmethod def create_catalog(cls, **_kwargs): calls.append(1) return fake_catalog monkeypatch.setattr(runtime_loader, "get_tool_factory", lambda: _Factory) manager = MoviePilotToolsManager(session_id="session", user_id="user") manager._load_tools() assert calls == [1] assert manager.catalog is fake_catalog assert manager.tools == [fake_tool] assert manager._plugin_agent_tools_revision == 9 def test_reply_mode_identity_and_display_message_contract() -> None: """旧编排路径必须复用同一枚举,展示消息委托保持原有结构。""" from app.agent.contracts import ReplyMode, build_display_message from app.agent.orchestrator import MoviePilotAgent from app.agent.orchestrator import ReplyMode as LegacyReplyMode assert LegacyReplyMode is ReplyMode contract_message = build_display_message( role="assistant", content="done", attachments=[{"name": "report.txt"}], status="streaming", ) legacy_message = MoviePilotAgent.build_display_message( role="assistant", content="done", attachments=[{"name": "report.txt"}], status="streaming", ) for message in (contract_message, legacy_message): assert message["id"].startswith("assistant-") assert isinstance(message["createdAt"], int) message.pop("id") message.pop("createdAt") assert legacy_message == contract_message def test_llm_facade_resolves_only_requested_public_module() -> None: """访问 capability 导出时不应顺带加载 helper 或 provider registry。""" result = _run_isolated( """ import json import sys import app.agent.llm as llm capability = llm.AgentCapabilityManager print(json.dumps({ "module": capability.__module__, "helper_loaded": "app.agent.llm.helper" in sys.modules, "provider_loaded": "app.agent.llm.provider" in sys.modules, })) """ ) assert result == { "module": "app.agent.llm.capability", "helper_loaded": False, "provider_loaded": False, }