Files
MoviePilot/tests/test_agent_data_ports.py
Aqr-K 7584d4b9fc fix: isolate agent sessions and retain runtime resource ownership (#7149)
* fix: arbitrate subscription identity and restore workflow state

* fix: isolate agent sessions and retain runtime resource ownership

* test: preserve legacy empty-owner subscription transactions

* test: handle reaped terminal descendants during shutdown polling

* fix: align shared atomic configuration method with lifecycle prerequisite

* test: cover subagent cancellation and completion lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:20:59 +08:00

564 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 数据上下文与类型化任务适配器测试。
"""
import json
from contextlib import asynccontextmanager
from dataclasses import FrozenInstanceError, replace
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.agent.tools.factory as tool_factory_module
import app.application.agent as agent_services
from app.agent.manager import AgentManager
from app.agent.memory import MemoryManager
from app.agent.orchestrator import MoviePilotAgent
from app.agent.session import AgentSessionOwner, _MessageTask
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.agent_task import AgentTaskTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.application.agent import AgentDataContext
from app.application.agenttask import AgentTaskRepository, AgentTaskSnapshot
from app.application.filtering import FilterRuleService
from app.db.adapters.agent import TransactionalAgentTaskRepository
from app.db.session import SessionFactory
# Scheduler 由惰性稳定入口导出,Pylint 无法静态解析。
# pylint: disable=no-name-in-module
from app.scheduler import Scheduler
from app.schemas.rule import CustomRule
from app.schemas.system import FilterRuleGroup
from app.schemas.types import NotificationChannel, SystemConfigKey
from app.startup.initializers import agent as agent_initializer
def _context(tasks: AgentTaskRepository) -> AgentDataContext:
"""
构造仅用于验证依赖身份的 Agent 数据上下文。
"""
dependency = cast(object, SimpleNamespace())
return AgentDataContext(
chat=cast(object, dependency),
chat_persistence=cast(object, dependency),
tasks=tasks,
users=cast(object, dependency),
sites=cast(object, dependency),
subscriptions=cast(object, dependency),
subscription_mutation_scope=cast(object, dependency),
subscription_delete_scope=cast(object, dependency),
async_rule_group_mutation_scope=cast(object, dependency),
subscription_history=cast(object, dependency),
transfer_history=cast(object, dependency),
transfer_execution=cast(object, dependency),
download_history=cast(object, dependency),
plugin_data=cast(object, dependency),
)
def test_agent_tool_receives_exact_data_context() -> None:
"""
内置工具只能消费构造时注入的同一上下文,不再查询全局注册表。
"""
repository = TransactionalAgentTaskRepository(SessionFactory)
context = _context(repository)
tool = AgentTaskTool(session_id="session", user_id="user", data=context)
assert tool.data is context
assert tool.data.tasks is repository
@pytest.mark.asyncio
async def test_delete_rule_group_uses_injected_atomic_mutation_scope(monkeypatch) -> None:
"""
Agent 删除规则组必须一次提交定义和全部引用并在释放作用域后广播。
"""
mutation = MagicMock()
mutation.apply = AsyncMock(return_value=SimpleNamespace(to_dict=lambda: {"subscribes": []}))
@asynccontextmanager
async def mutation_scope():
"""
提供可观测的异步规则组事务作用域。
"""
yield mutation
context = replace(
_context(TransactionalAgentTaskRepository(SessionFactory)),
async_rule_group_mutation_scope=mutation_scope,
)
monkeypatch.setattr(
"app.application.filtering.get_rule_groups",
lambda: [FilterRuleGroup(name="old", rule_string="4K")],
)
publish = AsyncMock()
service = FilterRuleService(context.subscriptions, mutation_scope, publish)
result = await service.delete_group("old")
assert result["count"] == 0
mutation.apply.assert_awaited_once_with(
[],
expected_rule_groups=[{"name": "old", "rule_string": "4K"}],
previous_name="old",
)
publish.assert_awaited_once()
@pytest.mark.asyncio
async def test_custom_rule_rename_commits_rule_and_group_definitions_together(
monkeypatch,
) -> None:
"""
自定义规则改名必须用一个事务同时提交规则本体和规则组表达式。
"""
mutation = MagicMock()
mutation.apply = AsyncMock(return_value=SimpleNamespace())
@asynccontextmanager
async def mutation_scope():
"""
提供可观测的异步组合事务作用域。
"""
yield mutation
context = replace(
_context(TransactionalAgentTaskRepository(SessionFactory)),
async_rule_group_mutation_scope=mutation_scope,
)
monkeypatch.setattr(
"app.application.filtering.get_custom_rules",
lambda: [CustomRule(id="OLD", name="旧规则", include="old")],
)
monkeypatch.setattr(
"app.application.filtering.get_rule_groups",
lambda: [FilterRuleGroup(name="group", rule_string="OLD & 4K")],
)
publish = AsyncMock()
service = FilterRuleService(context.subscriptions, mutation_scope, publish)
result = await service.update_custom(
current_rule_id="OLD",
new_rule_id="NEW",
)
assert result["custom_rule"]["id"] == "NEW"
mutation.apply.assert_awaited_once_with(
[{"name": "group", "rule_string": "NEW & 4K"}],
expected_rule_groups=[{"name": "group", "rule_string": "OLD & 4K"}],
custom_rules=[{"id": "NEW", "name": "旧规则", "include": "old"}],
expected_custom_rules=[{"id": "OLD", "name": "旧规则", "include": "old"}],
)
assert publish.await_count == 2
@pytest.mark.asyncio
async def test_custom_rule_reorder_preserves_latest_definitions_and_checks_expected_order(
monkeypatch,
) -> None:
"""
自定义规则重排只能改变顺序,并拒绝过期顺序覆盖当前列表。
"""
mutation = MagicMock()
mutation.apply = AsyncMock(return_value=SimpleNamespace())
@asynccontextmanager
async def mutation_scope():
"""
提供可观测的异步组合事务作用域。
"""
yield mutation
rules = [
CustomRule(id="A", name="A", include="latest-a"),
CustomRule(id="B", name="B", exclude="latest-b"),
]
groups = [FilterRuleGroup(name="group", rule_string="A > B")]
monkeypatch.setattr("app.application.filtering.get_custom_rules", lambda: rules)
monkeypatch.setattr("app.application.filtering.get_rule_groups", lambda: groups)
publish = AsyncMock()
service = FilterRuleService(cast(object, MagicMock()), mutation_scope, publish)
result = await service.reorder_custom(["B", "A"], expected_rule_ids=["A", "B"])
assert result["rule_ids"] == ["B", "A"]
mutation.apply.assert_awaited_once_with(
[{"name": "group", "rule_string": "A > B"}],
expected_rule_groups=[{"name": "group", "rule_string": "A > B"}],
custom_rules=[
{"id": "B", "name": "B", "exclude": "latest-b"},
{"id": "A", "name": "A", "include": "latest-a"},
],
expected_custom_rules=[
{"id": "A", "name": "A", "include": "latest-a"},
{"id": "B", "name": "B", "exclude": "latest-b"},
],
)
publish.assert_awaited_once_with(
SystemConfigKey.CustomFilterRules,
[
{"id": "B", "name": "B", "exclude": "latest-b"},
{"id": "A", "name": "A", "include": "latest-a"},
],
)
with pytest.raises(ValueError, match="顺序已被其他请求修改"):
await service.reorder_custom(["B", "A"], expected_rule_ids=["B", "A"])
@pytest.mark.asyncio
async def test_rule_group_reorder_uses_atomic_scope_and_rejects_changed_collection(
monkeypatch,
) -> None:
"""
规则组重排必须走原子作用域,并拒绝缺项或新增项的列表。
"""
mutation = MagicMock()
mutation.apply = AsyncMock(return_value=SimpleNamespace())
@asynccontextmanager
async def mutation_scope():
"""
提供可观测的规则组异步事务作用域。
"""
yield mutation
groups = [
FilterRuleGroup(name="first", rule_string="4K"),
FilterRuleGroup(name="second", rule_string="1080P"),
]
monkeypatch.setattr("app.application.filtering.get_rule_groups", lambda: groups)
publish = AsyncMock()
service = FilterRuleService(cast(object, MagicMock()), mutation_scope, publish)
result = await service.reorder_groups(
["second", "first"],
expected_group_names=["first", "second"],
)
assert result["group_names"] == ["second", "first"]
mutation.apply.assert_awaited_once_with(
[
{"name": "second", "rule_string": "1080P"},
{"name": "first", "rule_string": "4K"},
],
expected_rule_groups=[
{"name": "first", "rule_string": "4K"},
{"name": "second", "rule_string": "1080P"},
],
)
publish.assert_awaited_once()
with pytest.raises(ValueError, match="集合已变化"):
await service.reorder_groups(["first"])
def test_agent_service_facade_resolves_registered_dependencies(monkeypatch) -> None:
"""
Agent 服务门面应稳定处理未装配状态并转发组合根注入能力。
"""
provider_names = (
"_agent_manager_provider",
"_running_agent_manager_provider",
"_prompt_manager_provider",
"_agent_capability_manager_provider",
"_llm_helper_provider",
"_manual_redo_prompt_builder_provider",
)
for provider_name in provider_names:
monkeypatch.setattr(agent_services, provider_name, None)
assert agent_services.get_running_agent_manager() is None
with pytest.raises(RuntimeError, match="agent_manager 未注册"):
agent_services.get_agent_manager()
manager = object()
prompt_manager = object()
capability_manager = MagicMock()
capability_manager.is_audio_input_available.return_value = True
capability_manager.transcribe_audio.return_value = "转写结果"
llm_helper = MagicMock()
llm_helper.supports_image_input.return_value = True
agent_services.register_agent_services(
agent_manager=manager,
prompt_manager=prompt_manager,
capability_manager=capability_manager,
llm_helper=llm_helper,
)
assert agent_services.get_agent_manager() is manager
assert agent_services.get_running_agent_manager() is manager
assert agent_services.get_prompt_manager() is prompt_manager
assert agent_services.supports_image_input(
provider="openai",
model="vision-model",
base_url="https://example.com",
base_url_preset="custom",
)
llm_helper.supports_image_input.assert_called_once_with(
provider="openai",
model="vision-model",
base_url="https://example.com",
base_url_preset="custom",
)
assert agent_services.is_audio_input_available()
assert agent_services.transcribe_audio(b"audio", filename="voice.wav") == "转写结果"
capability_manager.transcribe_audio.assert_called_once_with(
b"audio",
filename="voice.wav",
)
with pytest.raises(RuntimeError, match="提示词构建器未注册"):
agent_services.build_manual_redo_prompt(object())
agent_services.register_agent_services(
agent_manager=manager,
prompt_manager=prompt_manager,
capability_manager=capability_manager,
llm_helper=llm_helper,
manual_redo_prompt_builder=lambda history: f"redo:{history}",
)
assert agent_services.build_manual_redo_prompt("history") == "redo:history"
def test_tool_factory_injects_context_only_into_builtin_tools(monkeypatch) -> None:
"""
工具工厂必须把上下文传给宿主工具,同时保持插件构造 ABI 不变。
"""
repository = TransactionalAgentTaskRepository(SessionFactory)
context = _context(repository)
monkeypatch.setattr(
MoviePilotToolFactory,
"_get_builtin_tool_classes",
classmethod(lambda _cls, _channel=None: [AgentTaskTool]),
)
monkeypatch.setattr(tool_factory_module, "_get_plugin_agent_tools", lambda: [])
tools = MoviePilotToolFactory.create_tools(
session_id="session",
user_id="user",
data=context,
)
assert len(tools) == 1
assert tools[0].data is context
@pytest.mark.asyncio
@pytest.mark.parametrize("entrypoint", ["channel", "web", "openai"])
async def test_session_entrypoints_inject_data_and_memory_into_agent_tasks(
monkeypatch, entrypoint: str,
) -> None:
"""
渠道和协议工厂必须复用宿主上下文,支持同一会话创建及查询任务。
"""
from app.agent.web import _get_web_agent_type
from app.api.endpoints.openai import _get_collecting_agent_type
repository = TransactionalAgentTaskRepository(SessionFactory)
chat = MagicMock()
chat.get_sync.return_value = None
context = replace(_context(repository), chat=chat)
memory = MemoryManager()
owner = AgentSessionOwner(data=context, memory=memory)
factory = {
"channel": None,
"web": _get_web_agent_type,
"openai": _get_collecting_agent_type,
}[entrypoint]
agent_factory = factory() if factory is not None else None
channel = NotificationChannel.WebAgent.value if entrypoint == "web" else None
monkeypatch.setattr(
MoviePilotToolFactory,
"_get_builtin_tool_classes",
classmethod(lambda _cls, _channel=None: [AgentTaskTool]),
)
monkeypatch.setattr(tool_factory_module, "_get_plugin_agent_tools", lambda: [])
monkeypatch.setattr(
"app.agent.tools.impl.agent_task.get_runtime_setting",
lambda key: {"AI_AGENT_ENABLE": True, "TZ": "UTC"}[key],
)
register_job = MagicMock(return_value=None)
monkeypatch.setattr("app.application.scheduling.update_agent_task_job", register_job)
monkeypatch.setattr("app.application.scheduling.get_agent_task_next_run", lambda _id: None)
async def run_blocking(_tool, _kind, function, *args, **kwargs):
"""
在测试临时库中执行同步仓储,避免创建全局阻塞执行器。
"""
return function(*args, **kwargs)
async def process(agent, action: str, **_kwargs) -> str:
"""
保留真实 Agent 与工具构造,只替换外部模型推理。
"""
tool = agent._initialize_tools()[0]
if action == "create":
return await tool.run(
action="create", name="入口依赖回归", content="检查数据注入",
trigger_type="cron", trigger="0 8 * * *",
)
return await tool.run(action="list")
monkeypatch.setattr(AgentTaskTool, "run_blocking", run_blocking)
monkeypatch.setattr(MoviePilotAgent, "process", process)
task_kwargs = {
"session_id": f"data-context-{entrypoint}",
"user_id": f"data-context-user-{entrypoint}",
"source": entrypoint,
"channel": channel,
"agent_factory": agent_factory,
}
try:
created = json.loads(await owner._process_message_internal(
_MessageTask(message="create", **task_kwargs),
))
agent = owner.active_agents[task_kwargs["session_id"]]
listed = json.loads(await owner._process_message_internal(
_MessageTask(message="list", **task_kwargs),
))
assert owner.active_agents[task_kwargs["session_id"]] is agent
assert agent._data is context
assert agent._memory is memory
assert agent._initialize_subagent_tools()[0].data is context
assert listed["total"] == 1
assert listed["tasks"][0]["id"] == created["id"]
saved = repository.get(created["id"], user_id=task_kwargs["user_id"])
assert saved is not None
assert saved.user_id == task_kwargs["user_id"]
assert saved.session_id == task_kwargs["session_id"]
assert saved.channel == channel
register_job.assert_called_once_with(created["id"])
finally:
for task in repository.list(user_id=task_kwargs["user_id"]):
repository.delete(task.id, user_id=task_kwargs["user_id"])
def test_tool_manager_reassembly_invalidates_factory_catalog() -> None:
"""
重复装配数据上下文时必须丢弃持有旧仓储的工厂工具快照。
"""
first = _context(TransactionalAgentTaskRepository(SessionFactory))
second = _context(TransactionalAgentTaskRepository(SessionFactory))
manager = MoviePilotToolsManager(data=first)
manager.tools = [AgentTaskTool(session_id="session", user_id="user", data=first)]
manager._catalog_materialized = True
manager._catalog_managed_by_factory = True
manager.set_data_context(second)
assert manager._data is second
assert manager.tools == []
assert manager.catalog is None
assert manager._catalog_materialized is False
def test_memory_manager_reads_only_injected_chat_service() -> None:
"""
记忆恢复必须调用构造时注入的会话服务,不再查询数据 locator。
"""
chat = MagicMock()
chat.get_sync.return_value = SimpleNamespace(agent_messages=[])
manager = MemoryManager(chat=chat, persistence=MagicMock())
assert manager.get_agent_messages("session", "user") == []
chat.get_sync.assert_called_once_with(session_id="session", user_id="user")
def test_scheduler_reads_injected_agent_task_repository() -> None:
"""
Scheduler 注册任务时必须读取显式注入的 AgentTask 仓储。
"""
repository = MagicMock()
repository.list.return_value = [SimpleNamespace(id=9)]
scheduler = object.__new__(Scheduler)
Scheduler.__init__(scheduler)
scheduler.update_agent_task_job = MagicMock()
scheduler.configure_agent_tasks(repository)
scheduler.init_agent_task_jobs()
repository.list.assert_called_once_with(enabled=True)
scheduler.update_agent_task_job.assert_called_once_with(9)
def test_agent_initializer_reassembly_replaces_cached_manager(monkeypatch) -> None:
"""
组合根重装配后不得复用持有旧数据上下文的 manager。
"""
first = _context(TransactionalAgentTaskRepository(SessionFactory))
second = _context(TransactionalAgentTaskRepository(SessionFactory))
monkeypatch.setattr(agent_initializer, "_agent_data_context", None)
monkeypatch.setattr(agent_initializer, "_injected_agent_manager", None)
monkeypatch.setattr(
"app.agent.orchestrator.memory_manager",
MemoryManager(),
)
agent_initializer.configure_agent_data_context(first)
first_manager = agent_initializer._get_injected_agent_manager()
agent_initializer.configure_agent_data_context(second)
second_manager = agent_initializer._get_injected_agent_manager()
assert first_manager is not second_manager
assert first_manager._data is first
assert second_manager._data is second
def test_agent_initializer_resolves_data_bound_manager_for_agent_task(monkeypatch) -> None:
"""
生产解析路径必须返回带数据上下文的 manager,供 agent_task 使用。
"""
context = _context(TransactionalAgentTaskRepository(SessionFactory))
monkeypatch.setattr(agent_initializer, "agent_manager", None)
monkeypatch.setattr(agent_initializer, "_agent_data_context", None)
monkeypatch.setattr(agent_initializer, "_injected_agent_manager", None)
canonical = AgentManager(memory=MemoryManager())
monkeypatch.setattr(agent_initializer, "get_runtime_agent_manager", lambda: canonical)
agent_initializer.configure_agent_data_context(context)
manager = agent_initializer._get_agent_manager()
assert manager._data is context
assert manager is canonical
def test_transactional_agent_task_repository_projects_frozen_snapshots() -> None:
"""
任务写入提交后必须返回脱离 Session 的冻结快照。
"""
repository = TransactionalAgentTaskRepository(SessionFactory)
task = repository.add(
name="架构检查",
content="检查 Agent 数据边界",
trigger_type="cron",
cron_expression="0 8 * * *",
run_at=None,
user_id="user-1",
username="tester",
session_id="session-1",
channel=None,
source="test",
original_chat_id=None,
)
assert isinstance(task, AgentTaskSnapshot)
assert repository.get(task.id, user_id="user-1") == task
assert repository.list(user_id="user-1") == [task]
with pytest.raises(FrozenInstanceError):
# 只验证冻结快照禁止赋值,故意执行类型检查器拒绝的写入。
task.name = "不可修改" # type: ignore[misc]
assert repository.update(
task.id,
{"name": "已更新"},
user_id="user-1",
)
updated = repository.get(task.id, user_id="user-1")
assert updated is not None
assert updated.name == "已更新"
assert repository.delete(task.id, user_id="user-1")
assert repository.get(task.id, user_id="user-1") is None