import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from langchain_core.messages import AIMessage, HumanMessage from app.agent import ( HEARTBEAT_SESSION_PREFIX, MoviePilotAgent, AgentManager, ReplyMode, UNSUPPORTED_IMAGE_INPUT_MESSAGE, _MessageTask, ) from app.agent.memory import memory_manager from app.agent.middleware.activity_log import QUERY_ACTIVITY_LOG_TOOL_NAME 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.tools.factory import MoviePilotToolFactory from app.core.config import settings from app.utils.identity import SYSTEM_INTERNAL_USER_ID 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): raise self._error # 保持 async generator 形态,避免测试替身变成普通 coroutine。 yield None class _FakeStreamingAgent(_FakeAgent): async def astream(self, _messages, **_kwargs): return # 保持 async generator 形态,当前用例不需要实际 token。 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_activity_log_middleware(tool=None): """构造带 tools 属性的 ActivityLogMiddleware 测试替身。""" return SimpleNamespace(name="activity", tools=[] if tool is None else [tool]) class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase): async def test_background_non_streaming_does_not_send_by_default(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() self.assertEqual("后台结果", agent._streamed_output) 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"}}, ] ) ] ) self.assertEqual(UNSUPPORTED_IMAGE_INPUT_MESSAGE, result) agent.send_agent_message.assert_awaited_once_with( UNSUPPORTED_IMAGE_INPUT_MESSAGE, title="" ) self.assertEqual(UNSUPPORTED_IMAGE_INPUT_MESSAGE, agent._streamed_output) 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"}}, ] ) ] ) self.assertEqual(UNSUPPORTED_IMAGE_INPUT_MESSAGE, result) 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 = ( "智能助手执行失败: No streaming chunk received for 120.0s " "(model=mimo-v2.5-pro, chunks_received=1)." ) self.assertEqual(expected, result) agent.send_agent_message.assert_awaited_once_with(expected, title="") sent_message = agent.send_agent_message.await_args.args[0] self.assertIn("No streaming chunk received for 120.0s", sent_message) self.assertNotIn("Tune or disable", sent_message) self.assertEqual(expected, agent._streamed_output) 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, "save_agent_messages") as save_messages: await agent._execute_agent([HumanMessage(content="测试")]) save_messages.assert_called_once() _, kwargs = save_messages.call_args self.assertEqual("tool-reply", kwargs["session_id"]) self.assertEqual("user-1", kwargs["user_id"]) self.assertEqual("消息已发送", 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, "get_agent_messages", return_value=cached_messages ), patch.object(agent, "prepare_chat_title", new=AsyncMock()), patch.object(agent, "_save_display_history_messages"), ): result = await agent.process("继续") self.assertEqual("消息已发送", result) self.assertEqual(1, len(cached_messages)) self.assertIsNot(cached_messages, captured["messages"]) self.assertEqual(2, len(captured["messages"])) 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, "save_agent_messages") as save_messages: await agent._execute_agent([]) agent.send_agent_message.assert_awaited_once_with( "后台结果", title="MoviePilot助手" ) save_messages.assert_not_called() self.assertEqual("后台结果", 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() self.assertEqual("后台结果", agent._streamed_output) async def test_heartbeat_check_jobs_captures_final_reply_and_keeps_message_tools(self): manager = AgentManager() with ( patch("app.agent.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 self.assertEqual(ReplyMode.CAPTURE_ONLY, kwargs["reply_mode"]) self.assertTrue(kwargs["allow_message_tools"]) async def test_heartbeat_check_jobs_skips_when_no_active_jobs(self): manager = AgentManager() with ( patch("app.agent.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_excludes_activity_log_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.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.create_subagent_middlewares", return_value=([], [])), patch( "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( "app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: _fake_skills_middleware(), ), patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"), patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), patch( "app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(), ), patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"), patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"), patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), patch("app.agent.InMemorySaver", return_value="checkpointer"), patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), ): created = await agent._create_agent(streaming=False) self.assertEqual( [ "skills", "jobs", "runtime", "memory", "summary", "patch", "usage", ], [getattr(item, "name", item) for item in created["middleware"]], ) async def test_create_agent_registers_skill_tool_from_middleware(self): """SkillsMiddleware 暴露的 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): captured["selection_tools"] = kwargs["selection_tools"] captured["always_include"] = kwargs["always_include"] return "selector" with ( patch.object(settings, "LLM_MAX_TOOLS", 5), patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())), patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.create_subagent_middlewares", return_value=([], [])), patch( "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( "app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: _fake_skills_middleware(skill_tool), ), patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), patch( "app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime", ), patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), patch( "app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(), ), patch( "app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary", ), patch( "app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch", ), patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector), patch("app.agent.InMemorySaver", return_value="checkpointer"), patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), ): created = await agent._create_agent(streaming=False) self.assertIn(skill_tool, created["tools"]) self.assertIn(skill_tool, captured["selection_tools"]) self.assertIn(SKILL_TOOL_NAME, captured["always_include"]) async def test_create_agent_excludes_activity_log_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.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.create_subagent_middlewares", return_value=([], [])), patch( "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( "app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: _fake_skills_middleware(), ), patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), patch( "app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime", ), patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), patch( "app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(), ), patch( "app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary", ), patch( "app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch", ), patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), patch("app.agent.InMemorySaver", return_value="checkpointer"), patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), ): created = await agent._create_agent(streaming=False) self.assertEqual( [ "skills", "jobs", "runtime", "memory", "summary", "patch", "usage", ], [getattr(item, "name", item) for item in created["middleware"]], ) 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] ) self.assertNotIn("send_message", always_include) def test_activity_log_tool_is_not_registered_by_tool_factory(self): """活动日志查询工具不应再由全局工具工厂保留。""" activity_log_tool = SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME) always_include = MoviePilotToolFactory.get_tool_selector_always_include_names( [activity_log_tool] ) self.assertNotIn(QUERY_ACTIVITY_LOG_TOOL_NAME, always_include) async def test_create_agent_registers_activity_log_tool_from_middleware(self): """ActivityLogMiddleware 暴露的工具应进入 Agent 工具和筛选候选。""" captured = {} activity_tool = SimpleNamespace(name=QUERY_ACTIVITY_LOG_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): captured["selection_tools"] = kwargs["selection_tools"] captured["always_include"] = kwargs["always_include"] return "selector" with ( patch.object(settings, "LLM_MAX_TOOLS", 5), patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())), patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.create_subagent_middlewares", return_value=([], [])), patch( "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( "app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: _fake_skills_middleware(), ), patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), patch( "app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime", ), patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), patch( "app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: _fake_activity_log_middleware( activity_tool ), ), patch( "app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary", ), patch( "app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch", ), patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector), patch("app.agent.InMemorySaver", return_value="checkpointer"), patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), ): created = await agent._create_agent(streaming=False) self.assertIn(activity_tool, created["tools"]) self.assertIn(activity_tool, captured["selection_tools"]) self.assertIn(QUERY_ACTIVITY_LOG_TOOL_NAME, captured["always_include"]) 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): captured["always_include"] = kwargs["always_include"] return "selector" with ( patch.object(settings, "LLM_MAX_TOOLS", 5), patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())), patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch( "app.agent.create_subagent_middlewares", return_value=( ["subagent"], [ SimpleNamespace(name=SUBAGENT_TASK_TOOL_NAME), SimpleNamespace(name=SUBAGENT_CONTROL_TOOL_NAME), ], ), ), patch( "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( "app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: _fake_skills_middleware(), ), patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), patch( "app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime", ), patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), patch( "app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(), ), patch( "app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary", ), patch( "app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch", ), patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector), patch("app.agent.InMemorySaver", return_value="checkpointer"), patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), ): await agent._create_agent(streaming=False) self.assertIn(SUBAGENT_TASK_TOOL_NAME, captured["always_include"]) self.assertIn(SUBAGENT_CONTROL_TOOL_NAME, captured["always_include"]) async def test_create_agent_keeps_activity_log_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.prompt_manager.get_agent_prompt", return_value="PROMPT"), patch("app.agent.create_subagent_middlewares", return_value=([], [])), patch( "app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names", return_value=[], ), patch( "app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: _fake_skills_middleware(), ), patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"), patch( "app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime", ), patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"), patch( "app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(), ), patch( "app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary", ), patch( "app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch", ), patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"), patch("app.agent.InMemorySaver", return_value="checkpointer"), patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs), ): created = await agent._create_agent(streaming=False) self.assertEqual( [ "skills", "jobs", "runtime", "memory", "activity", "summary", "patch", "usage", ], [getattr(item, "name", item) for item in created["middleware"]], ) async def test_run_background_prompt_forces_disable_message_tools_when_capture_only(self): captured = {} async def fake_process(self, message, images=None, files=None): captured["message"] = message captured["reply_mode"] = self.reply_mode captured["allow_message_tools"] = self.allow_message_tools captured["user_id"] = self.user_id with ( patch.object(MoviePilotAgent, "process", new=fake_process), patch.object(MoviePilotAgent, "cleanup", new=AsyncMock()), patch.object(memory_manager, "clear_memory"), ): await AgentManager.run_background_prompt( message="background task", reply_mode=ReplyMode.CAPTURE_ONLY, allow_message_tools=True, ) self.assertEqual("background task", captured["message"]) self.assertEqual(ReplyMode.CAPTURE_ONLY, captured["reply_mode"]) self.assertFalse(captured["allow_message_tools"]) self.assertEqual(SYSTEM_INTERNAL_USER_ID, captured["user_id"])