mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-23 08:34:28 +08:00
788 lines
32 KiB
Python
788 lines
32 KiB
Python
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"])
|