Files
MoviePilot/tests/test_agent_background_output.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

926 lines
37 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 后台任务输出在前台会话和通知渠道中的处理方式。
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from app.agent.contracts import ReplyMode
from app.agent.manager import AgentManager
from app.agent.memory import memory_manager
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME, InvocationMiddleware
from app.agent.middleware.memory import SEARCH_MEMORY_TOOL_NAME
from app.agent.middleware.output import READ_TOOL_RESULT_NAME, ToolOutputMiddleware
from app.agent.middleware.plan import PLAN_TOOL_NAME, PlanMiddleware
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME, ToolSelectorMiddleware
from app.agent.middleware.skills import SKILL_TOOL_NAME
from app.agent.middleware.subagents import (
SUBAGENT_CONTROL_TOOL_NAME,
SUBAGENT_TASK_TOOL_NAME,
)
from app.agent.orchestrator import (
HEARTBEAT_SESSION_PREFIX,
UNSUPPORTED_IMAGE_INPUT_MESSAGE,
MoviePilotAgent,
)
from app.agent.session import _MessageTask
from app.agent.tools.factory import MoviePilotToolFactory
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
from app.runtime.config import settings
pytestmark = pytest.mark.anyio
class _FakeGraphState:
"""
保存测试所需的消息快照,避免访问真实持久化。
"""
def __init__(self, messages):
"""
保存该测试图的预设消息或故障。
"""
self.values = {"messages": messages}
class _FakeAgent:
"""
提供可控的非流式图结果,不调用真实模型。
"""
def __init__(self, messages):
"""
保存该测试图的预设消息或故障。
"""
self._messages = messages
async def ainvoke(self, _payload, config=None):
"""
返回预设结果或抛出故障,不执行外部调用。
"""
return None
def get_state(self, _config):
"""
提供宿主结束处理所需的可恢复快照。
"""
return _FakeGraphState(self._messages)
class _FakeFailingAgent:
"""
在调用边界注入故障,验证宿主错误回执。
"""
def __init__(self, error):
"""
保存该测试图的预设消息或故障。
"""
self._error = error
async def ainvoke(self, _payload, config=None):
"""
返回预设结果或抛出故障,不执行外部调用。
"""
raise self._error
def get_state(self, _config):
"""
提供宿主结束处理所需的可恢复快照。
"""
return _FakeGraphState([])
class _FakeStreamingFailingAgent(_FakeFailingAgent):
"""
在流式迭代边界注入同一故障。
"""
async def astream(self, _messages, **_kwargs):
"""
维持异步流接口,按测试预设结束或失败。
"""
if _kwargs.get("__keep_async_generator__"):
yield None
raise self._error
class _FakeStreamingAgent(_FakeAgent):
"""
不产生额外 token,供测试读取最终图状态。
"""
async def astream(self, _messages, **_kwargs):
"""
维持异步流接口,按测试预设结束或失败。
"""
if _kwargs.get("__keep_async_generator__"):
yield None
class StreamChunkTimeoutError(RuntimeError):
"""
模拟 langchain_openai 的流式分块超时异常。
"""
def _fake_skills_middleware(tool=None):
"""
构造带 tools 属性的 SkillsMiddleware 测试替身。
"""
return SimpleNamespace(name="skills", tools=[] if tool is None else [tool])
def _fake_memory_middleware(tool=None):
"""
构造带 tools 属性的统一 MemoryMiddleware 测试替身。
"""
return SimpleNamespace(name="memory", tools=[] if tool is None else [tool])
def _capture_tool_selector(captured, **kwargs):
"""
保留真实发现工具与筛选合同,只替换无需执行的测试模型实例。
"""
selector_options = {**kwargs, "model": None}
selector = ToolSelectorMiddleware(**selector_options)
selector.model = kwargs["model"]
captured.update(
selector=selector,
selection_tools=selector.selection_tools,
always_include=selector.always_include,
enable_discovery=kwargs["enable_discovery"],
)
return selector
def _assert_internal_tool_registration(created, captured):
"""
核实内部计划、续读和发现工具的目录身份、常驻筛选及外层顺序。
"""
middlewares = created["middleware"]
plan = next(item for item in middlewares if isinstance(item, PlanMiddleware))
output = next(item for item in middlewares if isinstance(item, ToolOutputMiddleware))
selector = captured["selector"]
policy = middlewares[0]
assert policy.name == "AgentPolicyMiddleware"
assert middlewares[1].name == "CodeExecutionMiddleware"
assert middlewares[2] is output
assert not any(isinstance(item, InvocationMiddleware) for item in middlewares)
assert policy.catalog.resolve_unique(GET_TOOL_EXECUTION_NAME) is None
assert captured["enable_discovery"] is True
assert {PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME, TOOL_DISCOVERY_NAME} <= set(selector.always_include)
for tool in [*plan.tools, *output.tools, *selector.tools]:
assert policy.catalog.resolve_unique(tool.name).tool is tool
assert tool in selector.selection_tools
assert middlewares.index(plan) < middlewares.index(selector)
assert middlewares.index(selector) == len(middlewares) - 5
assert middlewares[-4].name == "CodeCaptureMiddleware"
assert middlewares[-3].name == "FinalRequestCompactionMiddleware"
assert middlewares[-2].name == "VisionMiddleware"
assert middlewares[-1] == "usage"
class TestAgentBackgroundOutput:
"""
验证后台任务的回复策略、流式结束行为和工具装配。
"""
async def test_non_streaming_image_unsupported_error_sends_friendly_notice(self):
"""
非流式图片能力错误只向用户交付友好说明。
"""
agent = MoviePilotAgent(session_id="image-test", user_id="user-1")
agent.channel = "Telegram"
agent.source = "telegram-test"
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(
return_value=_FakeFailingAgent(
RuntimeError("No endpoints found that support image input")
)
)
agent.send_agent_message = AsyncMock()
result, _ = await agent._execute_agent(
[
HumanMessage(
content=[
{"type": "text", "text": "看看这张图"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}},
]
)
]
)
assert result == UNSUPPORTED_IMAGE_INPUT_MESSAGE
agent.send_agent_message.assert_awaited_once_with(
UNSUPPORTED_IMAGE_INPUT_MESSAGE, title=""
)
assert agent._streamed_output == UNSUPPORTED_IMAGE_INPUT_MESSAGE
async def test_streaming_image_unsupported_error_sends_friendly_notice(self):
"""
流式图片能力错误沿用同一用户可见说明。
"""
agent = MoviePilotAgent(session_id="image-test", user_id="user-1")
agent.channel = "Telegram"
agent.source = "telegram-test"
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
set_dispatch_policy=lambda allow_dispatch_without_context=False: None,
start_streaming=AsyncMock(),
flush_pending_tool_summary=lambda: "",
stop_streaming=AsyncMock(return_value=(False, "")),
)
agent._should_stream = lambda: True
agent._create_agent = AsyncMock(
return_value=_FakeStreamingFailingAgent(
RuntimeError("Error code: 404 - No endpoints found that support image input")
)
)
agent.send_agent_message = AsyncMock()
result, _ = await agent._execute_agent(
[
HumanMessage(
content=[
{"type": "text", "text": "看看这张图"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}},
]
)
]
)
assert result == UNSUPPORTED_IMAGE_INPUT_MESSAGE
agent.send_agent_message.assert_awaited_once_with(
UNSUPPORTED_IMAGE_INPUT_MESSAGE, title=""
)
async def test_streaming_model_chunk_timeout_sends_friendly_notice(self):
"""
流式模型分块超时时应只把主错误信息发给用户。
"""
agent = MoviePilotAgent(session_id="timeout-test", user_id="user-1")
agent.channel = "Telegram"
agent.source = "telegram-test"
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
set_dispatch_policy=lambda allow_dispatch_without_context=False: None,
start_streaming=AsyncMock(),
flush_pending_tool_summary=lambda: "",
stop_streaming=AsyncMock(return_value=(False, "")),
)
agent._should_stream = lambda: True
raw_error = StreamChunkTimeoutError(
"No streaming chunk received for 120.0s "
"(model=mimo-v2.5-pro, chunks_received=1). "
"Tune or disable via the `stream_chunk_timeout` constructor kwarg."
)
agent._create_agent = AsyncMock(
return_value=_FakeStreamingFailingAgent(raw_error)
)
agent.send_agent_message = AsyncMock()
result, _ = await agent._execute_agent([HumanMessage(content="测试超时")])
expected = "智能助手执行失败,请稍后重试"
assert result == expected
agent.send_agent_message.assert_awaited_once_with(expected, title="")
sent_message = agent.send_agent_message.await_args.args[0]
assert "No streaming chunk received for 120.0s" not in sent_message
assert agent._streamed_output == expected
async def test_streaming_success_stops_streaming_once(self):
"""
流式正常完成时不应在 finally 中重复停止流式输出。
"""
agent = MoviePilotAgent(session_id="stream-ok", user_id="user-1")
agent.channel = "Telegram"
agent.source = "telegram-test"
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
set_dispatch_policy=lambda allow_dispatch_without_context=False: None,
start_streaming=AsyncMock(),
flush_pending_tool_summary=lambda: "",
stop_streaming=AsyncMock(return_value=(True, "已发送")),
)
agent._should_stream = lambda: True
agent._create_agent = AsyncMock(
return_value=_FakeStreamingAgent([AIMessage(content="已发送")])
)
agent.send_agent_message = AsyncMock()
await agent._execute_agent([HumanMessage(content="测试")])
agent.stream_handler.stop_streaming.assert_awaited_once()
async def test_tool_sent_reply_persists_raw_agent_messages(self):
"""
工具已发送用户回复时仍应保存可恢复的 Agent 原始消息。
"""
agent = MoviePilotAgent(session_id="tool-reply", user_id="user-1")
agent.channel = "Telegram"
agent.source = "telegram-test"
agent._tool_context = {"user_reply_sent": True}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="消息已发送")])
)
agent.send_agent_message = AsyncMock()
with patch.object(
memory_manager,
"async_save_agent_messages",
new=AsyncMock(),
) as save_messages:
await agent._execute_agent([HumanMessage(content="测试")])
save_messages.assert_called_once()
_, kwargs = save_messages.call_args
assert kwargs["session_id"] == "tool-reply"
assert kwargs["user_id"] == "user-1"
assert kwargs["messages"][0].content == "消息已发送"
async def test_process_does_not_mutate_cached_agent_messages(self):
"""
处理新消息时不应直接修改记忆缓存中的历史消息列表。
"""
agent = MoviePilotAgent(
session_id="cached-memory",
user_id="user-1",
channel="Telegram",
source="telegram-test",
)
cached_messages = [HumanMessage(content="上一轮")]
captured = {}
async def _execute_agent(messages):
"""
记录模拟图实际收到的当前用户输入。
"""
captured["messages"] = messages
return "消息已发送", {}
agent._execute_agent = AsyncMock(side_effect=_execute_agent)
with (
patch.object(
memory_manager,
"async_get_agent_messages",
new=AsyncMock(return_value=cached_messages),
),
patch.object(agent, "prepare_chat_title", new=AsyncMock()),
patch.object(agent, "_save_display_history_messages"),
):
result = await agent.process("继续")
assert result == "消息已发送"
assert len(cached_messages) == 1
assert cached_messages is not captured["messages"]
assert len(captured["messages"]) == 2
async def test_background_non_streaming_sends_when_reply_mode_dispatch(self):
"""
显式派发模式允许后台任务发送最终回复。
"""
agent = MoviePilotAgent(session_id="bg-test", user_id="system")
agent.channel = None
agent.source = None
agent.reply_mode = ReplyMode.DISPATCH
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="后台结果")])
)
agent.send_agent_message = AsyncMock()
with patch.object(
memory_manager,
"async_save_agent_messages",
new=AsyncMock(),
) as save_messages:
await agent._execute_agent([])
agent.send_agent_message.assert_awaited_once_with(
"后台结果", title="MoviePilot助手"
)
save_messages.assert_not_called()
assert agent._streamed_output == "后台结果"
async def test_background_non_streaming_captures_without_sending_when_capture_only(self):
"""
显式捕获模式保留最终结果,但不发送消息或保存渠道历史。
"""
agent = MoviePilotAgent(session_id="bg-test", user_id="system")
agent.channel = None
agent.source = None
agent.reply_mode = ReplyMode.CAPTURE_ONLY
agent._tool_context = {"user_reply_sent": False}
agent._streamed_output = ""
agent.stream_handler = SimpleNamespace(
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="后台结果")])
)
agent.send_agent_message = AsyncMock()
with patch.object(memory_manager, "save_agent_messages") as save_messages:
await agent._execute_agent([])
agent.send_agent_message.assert_not_awaited()
save_messages.assert_not_called()
assert agent._streamed_output == "后台结果"
async def test_heartbeat_check_jobs_captures_final_reply_and_keeps_message_tools(self):
"""
心跳捕获最终回复但保留任务实际需要的消息工具。
"""
manager = AgentManager()
with (
patch("app.agent.tasks.load_jobs_metadata", new=AsyncMock(return_value=[{
"id": "job-1",
"name": "测试任务",
"description": "desc",
"path": "/tmp/job-1/JOB.md",
"schedule": "once",
"status": "pending",
"last_run": None,
}])),
patch.object(manager, "_build_heartbeat_prompt", return_value="HEARTBEAT"),
patch.object(manager, "process_message", new=AsyncMock()) as process_message,
):
await manager.heartbeat_check_jobs()
process_message.assert_awaited_once()
kwargs = process_message.await_args.kwargs
assert kwargs["reply_mode"] == ReplyMode.CAPTURE_ONLY
assert kwargs["allow_message_tools"] is True
async def test_heartbeat_check_jobs_skips_when_no_active_jobs(self):
"""
没有启用任务时不调用模型执行心跳。
"""
manager = AgentManager()
with (
patch("app.agent.tasks.load_jobs_metadata", new=AsyncMock(return_value=[])),
patch.object(manager, "process_message", new=AsyncMock()) as process_message,
):
await manager.heartbeat_check_jobs()
process_message.assert_not_awaited()
async def test_agent_manager_preserves_voice_input_flag(self):
"""
会话队列执行时应把语音输入标记继续传给 Agent。
"""
manager = AgentManager()
agent = MoviePilotAgent(session_id="session-1", user_id="user-1")
manager.active_agents["session-1"] = agent
agent.process = AsyncMock(return_value="ok")
task = _MessageTask(
session_id="session-1",
user_id="user-1",
message="帮我推荐一部电影",
has_audio_input=True,
)
await manager._process_message_internal(task)
agent.process.assert_awaited_once_with(
"帮我推荐一部电影",
images=None,
files=None,
has_audio_input=True,
)
async def test_create_agent_disables_activity_recording_for_heartbeat_session(self):
"""
心跳任务保留统一记忆能力,但不启用渠道活动记录。
"""
agent = MoviePilotAgent(
session_id=f"{HEARTBEAT_SESSION_PREFIX}test__",
user_id="system",
)
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
patch("app.agent.orchestrator.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
assert [getattr(item, "name", item) for item in created["middleware"]] == [
"AgentPolicyMiddleware",
"CodeExecutionMiddleware",
"ToolOutputMiddleware",
"skills",
"jobs",
"runtime",
"PlanMiddleware",
"memory",
"ToolGuardrailsMiddleware",
"patch",
"CodeCaptureMiddleware",
"FinalRequestCompactionMiddleware",
"VisionMiddleware",
"usage",
]
async def test_create_agent_registers_skill_tool_from_middleware(self):
"""
SkillsMiddleware 暴露的 read_skill 应进入 Agent 工具和筛选候选。
"""
captured = {}
skill_tool = SimpleNamespace(name=SKILL_TOOL_NAME)
agent = MoviePilotAgent(session_id="normal-session", user_id="system")
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
def _tool_selector(**kwargs):
"""
记录技能能力进入真实筛选目录后的参数。
"""
return _capture_tool_selector(captured, **kwargs)
with (
patch.object(settings, "LLM_MAX_TOOLS", 5),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(skill_tool),
),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
assert skill_tool in created["tools"]
assert skill_tool in captured["selection_tools"]
assert SKILL_TOOL_NAME in captured["always_include"]
_assert_internal_tool_registration(created, captured)
async def test_create_agent_disables_activity_recording_without_message_context(self):
"""
无渠道信息的后台捕获任务不应启用活动记录。
"""
agent = MoviePilotAgent(
session_id="background-capture-session",
user_id="system",
output_callback=lambda _text: None,
)
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
assert [getattr(item, "name", item) for item in created["middleware"]] == [
"AgentPolicyMiddleware",
"CodeExecutionMiddleware",
"ToolOutputMiddleware",
"skills",
"jobs",
"runtime",
"PlanMiddleware",
"memory",
"ToolGuardrailsMiddleware",
"patch",
"CodeCaptureMiddleware",
"FinalRequestCompactionMiddleware",
"VisionMiddleware",
"usage",
]
def test_message_tool_is_not_always_included_by_tool_selector(self):
"""
消息发送工具不应绕过工具筛选。
"""
send_message_tool = SimpleNamespace(name="send_message")
always_include = MoviePilotToolFactory.get_tool_selector_always_include_names(
[send_message_tool]
)
assert "send_message" not in always_include
def test_memory_tool_is_always_included_by_tool_selector(self):
"""
记忆检索工具应作为常驻候选,但不由全局工具工厂重复注册。
"""
memory_tool = SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME)
always_include = MoviePilotToolFactory.get_tool_selector_always_include_names(
[memory_tool]
)
assert SEARCH_MEMORY_TOOL_NAME in always_include
async def test_create_agent_registers_memory_tool_from_middleware(self):
"""
MemoryMiddleware 暴露的工具应进入 Agent 工具和筛选候选。
"""
captured = {}
memory_tool = SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME)
agent = MoviePilotAgent(
session_id="normal-session",
user_id="system",
channel="Web",
source="openai",
)
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
def _tool_selector(**kwargs):
"""
记录活动日志能力进入真实筛选目录后的参数。
"""
return _capture_tool_selector(captured, **kwargs)
with (
patch.object(settings, "LLM_MAX_TOOLS", 5),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch(
"app.agent.orchestrator.MemoryMiddleware",
side_effect=lambda *args, **kwargs: _fake_memory_middleware(memory_tool),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
assert memory_tool in created["tools"]
assert memory_tool in captured["selection_tools"]
assert SEARCH_MEMORY_TOOL_NAME in captured["always_include"]
_assert_internal_tool_registration(created, captured)
async def test_create_agent_always_includes_subagent_tools(self):
"""
工具筛选开启时应保留同步和异步子代理入口。
"""
captured = {}
agent = MoviePilotAgent(session_id="normal-session", user_id="system")
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
def _tool_selector(**kwargs):
"""
记录子代理能力进入真实筛选目录后的参数。
"""
return _capture_tool_selector(captured, **kwargs)
with (
patch.object(settings, "LLM_MAX_TOOLS", 5),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch(
"app.agent.orchestrator.create_subagent_middlewares",
return_value=(
["subagent"],
[
SimpleNamespace(name=SUBAGENT_TASK_TOOL_NAME),
SimpleNamespace(name=SUBAGENT_CONTROL_TOOL_NAME),
],
),
),
patch(
"app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
assert SUBAGENT_TASK_TOOL_NAME in captured["always_include"]
assert SUBAGENT_CONTROL_TOOL_NAME in captured["always_include"]
_assert_internal_tool_registration(created, captured)
async def test_create_agent_uses_one_memory_middleware_for_normal_session(self):
"""
普通渠道会话用同一个中间件提供记忆检索和活动记录。
"""
agent = MoviePilotAgent(
session_id="normal-session",
user_id="system",
channel="Web",
source="openai",
)
agent._initialize_tools = lambda: []
agent._initialize_subagent_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.tools.factory.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
assert [getattr(item, "name", item) for item in created["middleware"]] == [
"AgentPolicyMiddleware",
"CodeExecutionMiddleware",
"ToolOutputMiddleware",
"skills",
"jobs",
"runtime",
"PlanMiddleware",
"memory",
"ToolGuardrailsMiddleware",
"patch",
"CodeCaptureMiddleware",
"FinalRequestCompactionMiddleware",
"VisionMiddleware",
"usage",
]
async def test_run_background_prompt_forces_disable_message_tools_when_capture_only(self):
"""
仅捕获的后台入口强制移除主动消息发送能力。
"""
captured = {}
manager = AgentManager()
async def fake_process(task):
"""
捕获后台参数而不触发真实 Agent 推理。
"""
captured["message"] = task.message
captured["reply_mode"] = task.reply_mode
captured["allow_message_tools"] = task.allow_message_tools
captured["user_id"] = task.user_id
manager._process_message_internal = fake_process
await manager.initialize()
try:
await manager.run_background_prompt(
message="background task",
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=True,
)
finally:
await manager.close()
assert captured["message"] == "background task"
assert captured["reply_mode"] == ReplyMode.CAPTURE_ONLY
assert captured["allow_message_tools"] is False
assert captured["user_id"] == SYSTEM_INTERNAL_USER_ID