Files
MoviePilot/tests/test_agent_activity_log.py
T

534 lines
18 KiB
Python

import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from app.agent.middleware.activity_log import (
ActivityLogMiddleware,
QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
QUERY_ACTIVITY_LOG_TOOL_NAME,
_summarize_with_llm,
load_activity_log_index,
query_activity_logs,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.tags import ToolTag
from app.runtime.tasks import TaskRegistry
def _write_activity_log(activity_dir, date_str: str, lines: list[str]) -> None:
"""写入测试用活动日志。"""
activity_dir.mkdir(parents=True, exist_ok=True)
body = "\n".join(lines)
(activity_dir / f"{date_str}.md").write_text(
f"# {date_str} 活动日志\n\n{body}\n",
encoding="utf-8",
)
async def _wait_activity_log_tasks(middleware: ActivityLogMiddleware) -> None:
"""等待活动日志后台任务完成,避免测试与后台写入竞态。"""
tasks = list(middleware._background_tasks)
if tasks:
await asyncio.gather(*tasks)
def test_activity_log_index_counts_entries_without_body(tmp_path):
"""活动日志索引只应包含条目数量,不暴露完整摘要正文。"""
date_str = datetime.now().strftime("%Y-%m-%d")
_write_activity_log(
tmp_path,
date_str,
[
"- **10:00** 帮用户整理了电影文件",
"- **11:00** 查询了下载任务状态",
],
)
index = load_activity_log_index(str(tmp_path), days=1)
assert index == {date_str: "2 条活动记录"}
assert "整理了电影文件" not in json.dumps(index, ensure_ascii=False)
def test_activity_log_prompt_is_stable_and_excludes_log_index(tmp_path):
"""ActivityLogMiddleware 的系统提示词不应随活动日志索引变化。"""
date_str = datetime.now().strftime("%Y-%m-%d")
_write_activity_log(
tmp_path,
date_str,
["- **10:00** 这是一条不应默认进入上下文的活动正文"],
)
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path), prompt_load_days=1)
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None))
request = SimpleNamespace(
state=state_update,
system_message=SystemMessage(content="SYSTEM"),
override=lambda **kwargs: SimpleNamespace(
state=state_update,
system_message=kwargs.get("system_message", SystemMessage(content="SYSTEM")),
),
)
modified = middleware.modify_request(request)
system_text = str(modified.system_message.content)
stable_prompt = middleware._format_activity_log(
{"2099-12-31": "999 条活动记录"}
)
assert "1 条活动记录" not in system_text
assert date_str not in system_text
assert "这是一条不应默认进入上下文的活动正文" not in system_text
assert "query_activity_log" in system_text
assert middleware._format_activity_log(state_update["activity_log_contents"]) == stable_prompt
def test_activity_log_abefore_agent_refreshes_existing_state(tmp_path):
"""复用 Agent 图时,活动日志索引仍应在每轮执行前刷新。"""
date_str = datetime.now().strftime("%Y-%m-%d")
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path), prompt_load_days=1)
state = {"activity_log_contents": {"old": "旧索引"}}
_write_activity_log(
tmp_path,
date_str,
["- **10:00** 新增活动记录"],
)
state_update = asyncio.run(middleware.abefore_agent(state, runtime=None))
assert state_update == {"activity_log_contents": {date_str: "1 条活动记录"}}
def test_activity_log_skips_trivial_greeting_without_llm(tmp_path):
"""无实际任务的寒暄不应调用 LLM,也不应写入活动日志。"""
async def _run_test():
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
summarize_mock = AsyncMock(return_value="不应写入")
append_mock = AsyncMock()
with (
patch(
"app.agent.middleware.activity_log._summarize_with_llm",
new=summarize_mock,
),
patch.object(middleware, "_append_activity", new=append_mock),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="你好"),
AIMessage(content="你好,有什么可以帮你?"),
],
},
runtime=None,
)
await _wait_activity_log_tasks(middleware)
return summarize_mock, append_mock
summarize_mock, append_mock = asyncio.run(_run_test())
summarize_mock.assert_not_awaited()
append_mock.assert_not_awaited()
assert not list(tmp_path.glob("*.md"))
def test_summarize_with_llm_ignores_skip_marker():
"""LLM 返回 SKIP 时应视为无需记录活动日志。"""
llm = SimpleNamespace(
ainvoke=AsyncMock(return_value=SimpleNamespace(content="SKIP"))
)
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 你好"))
assert summary is None
llm.ainvoke.assert_awaited_once()
def test_summarize_with_llm_extracts_text_blocks():
"""活动摘要应兼容 LLM 返回的结构化文本块。"""
llm = SimpleNamespace(
ainvoke=AsyncMock(
return_value=SimpleNamespace(
content=[
{"type": "reasoning", "text": "内部推理"},
{"type": "text", "text": "摘要:用户完成了文件工具排查。"},
]
)
)
)
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 排查文件工具"))
assert summary == "用户完成了文件工具排查。"
llm.ainvoke.assert_awaited_once()
def test_activity_log_records_detailed_summary(tmp_path):
"""有实际工具动作的交互应写入较完整的活动摘要。"""
summary = (
"用户要求整理 `/downloads/Show`,助手调用 transfer_file 识别并转移剧集,"
"结果成功写入目标媒体库。"
)
async def _run_test():
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
with patch(
"app.agent.middleware.activity_log._summarize_with_llm",
new=AsyncMock(return_value=summary),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="帮我整理 /downloads/Show"),
AIMessage(
content="",
tool_calls=[
{
"name": "transfer_file",
"args": {"path": "/downloads/Show"},
"id": "call_1",
}
],
),
ToolMessage(
content='{"success": true, "target": "/media/Show"}',
tool_call_id="call_1",
),
],
},
runtime=None,
)
await _wait_activity_log_tasks(middleware)
asyncio.run(_run_test())
log_files = list(tmp_path.glob("*.md"))
assert len(log_files) == 1
content = log_files[0].read_text(encoding="utf-8")
assert summary in content
assert "- **" in content
def test_activity_log_after_agent_does_not_wait_for_summary(tmp_path):
"""活动日志摘要生成应在后台执行,不阻塞当前 Agent 会话结束。"""
async def _slow_summarize(_conversation_text: str) -> str:
"""模拟较慢的活动摘要生成。"""
await asyncio.sleep(0.05)
return "用户要求检查下载任务,助手调用工具完成检查。"
async def _run_test():
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
append_mock = AsyncMock()
with (
patch(
"app.agent.middleware.activity_log._summarize_with_llm",
side_effect=_slow_summarize,
) as summarize_mock,
patch.object(middleware, "_append_activity", new=append_mock),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="帮我检查下载任务"),
AIMessage(
content="",
tool_calls=[
{
"name": "query_download_tasks",
"args": {},
"id": "call_1",
}
],
),
ToolMessage(
content='{"success": true}',
tool_call_id="call_1",
),
],
},
runtime=None,
)
called_before_wait = summarize_mock.await_count
pending_before_wait = len(middleware._background_tasks)
await _wait_activity_log_tasks(middleware)
return called_before_wait, pending_before_wait, summarize_mock, append_mock
called_before_wait, pending_before_wait, summarize_mock, append_mock = asyncio.run(
_run_test()
)
assert called_before_wait == 0
assert pending_before_wait == 1
summarize_mock.assert_awaited_once()
append_mock.assert_awaited_once_with("用户要求检查下载任务,助手调用工具完成检查。")
def test_activity_log_background_task_follows_host_shutdown(tmp_path):
"""活动摘要任务必须登记 owner,并随宿主关停取消和收敛。"""
async def _run_test():
"""启动阻塞摘要后关闭登记器,返回 owner 与最终任务状态。"""
registry = TaskRegistry()
started = asyncio.Event()
cancelled = asyncio.Event()
async def _blocked_record(_messages: list) -> None:
"""保持记录任务运行,直到宿主关停发出取消。"""
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
task_registry=registry,
)
with patch.object(middleware, "_record_activity", side_effect=_blocked_record):
middleware._schedule_activity_recording([])
await started.wait()
owners = tuple(record.owner for record in registry.records)
converged = await registry.shutdown(timeout_seconds=1.0)
await asyncio.sleep(0)
return owners, converged, cancelled.is_set(), middleware._background_tasks
owners, converged, cancelled, background_tasks = asyncio.run(_run_test())
assert owners == ("agent.activity_log.record",)
assert converged is True
assert cancelled is True
assert background_tasks == set()
def test_query_activity_logs_filters_by_keyword_and_date(tmp_path):
"""活动日志查询应支持日期和关键词过滤。"""
_write_activity_log(
tmp_path,
"2026-06-18",
[
"- **10:00** 帮用户整理了电影 A",
"- **10:30** 查询了站点状态",
],
)
_write_activity_log(
tmp_path,
"2026-06-17",
["- **09:00** 帮用户整理了电影 B"],
)
payload = query_activity_logs(
str(tmp_path),
keyword="整理",
date="2026-06-18",
limit=10,
)
assert payload["success"] is True
assert payload["total_count"] == 1
assert payload["entries"][0]["date"] == "2026-06-18"
assert payload["entries"][0]["time"] == "10:00"
assert payload["entries"][0]["summary"] == "帮用户整理了电影 A"
def test_query_activity_logs_supports_optional_regex(tmp_path):
"""活动日志查询应在显式开启时支持正则匹配。"""
_write_activity_log(
tmp_path,
"2026-06-18",
[
"- **10:00** 帮用户整理了剧集 A",
"- **10:30** 查询了站点状态",
],
)
payload = query_activity_logs(
str(tmp_path),
keyword="整理|站点",
use_regex=True,
date="2026-06-18",
limit=10,
)
assert payload["success"] is True
assert payload["use_regex"] is True
assert payload["total_count"] == 2
def test_query_activity_logs_reports_invalid_regex(tmp_path):
"""活动日志查询遇到无效正则时应返回结构化错误。"""
payload = query_activity_logs(
str(tmp_path),
keyword="[",
use_regex=True,
date="2026-06-18",
)
assert payload["success"] is False
assert "无效的活动日志正则表达式" in payload["message"]
assert payload["entries"] == []
def test_activity_log_middleware_exposes_query_tool(tmp_path):
"""ActivityLogMiddleware 应以中间件工具形式暴露活动日志查询。"""
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
assert [tool.name for tool in middleware.tools] == [QUERY_ACTIVITY_LOG_TOOL_NAME]
assert ToolTag.Read in middleware.tools[0].tags
assert ToolTag.System in middleware.tools[0].tags
assert "recent MoviePilot Agent activity logs" in middleware.tools[0].description
def test_activity_log_middleware_query_tool_returns_json_payload(tmp_path):
"""query_activity_log 中间件工具应返回结构化 JSON 查询结果。"""
_write_activity_log(
tmp_path,
"2026-06-18",
["- **10:00** 帮用户整理了电影 A"],
)
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
tool = middleware.tools[0]
result = asyncio.run(
tool.ainvoke({"keyword": "整理", "date": "2026-06-18", "limit": 5})
)
payload = json.loads(result)
assert payload["success"] is True
assert payload["returned_count"] == 1
assert payload["entries"][0]["summary"] == "帮用户整理了电影 A"
def test_activity_log_tool_call_records_streaming_summary(tmp_path):
"""query_activity_log 工具执行时应记录流式聚合摘要。"""
async def _run_test():
calls = []
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=lambda **kwargs: calls.append(kwargs),
)
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME),
tool_call={
"args": {
"keyword": "整理",
"date": "2026-06-18",
}
},
)
async def _fake_handler(_request):
"""返回模拟工具结果。"""
return "ok"
result = await middleware.awrap_tool_call(request, _fake_handler)
return result, calls
result, calls = asyncio.run(_run_test())
assert result == "ok"
assert calls == [
{
"tool_name": QUERY_ACTIVITY_LOG_TOOL_NAME,
"tool_message": QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
"tool_kwargs": {
"keyword": "整理",
"date": "2026-06-18",
},
}
]
def test_activity_log_middleware_sanitizes_its_own_logs(tmp_path):
"""活动日志中间件读取参数和异常写日志时必须脱敏。"""
async def _run_test():
secret_marker = "activity-secret-marker-6825"
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=MagicMock(),
)
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME),
tool_call={"args": {"keyword": f"token={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with patch("app.agent.middleware.activity_log.logger", mock_logger):
try:
await middleware.awrap_tool_call(request, _failing_handler)
except RuntimeError:
pass
else:
raise AssertionError("middleware should re-raise handler errors")
return secret_marker, mock_logger
secret_marker, mock_logger = asyncio.run(_run_test())
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
def test_activity_log_provider_error_does_not_echo_secret(tmp_path):
"""活动日志 provider 内部异常不能进入日志或模型错误结果。"""
secret_marker = "activity-provider-secret-3584"
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
mock_logger = MagicMock()
with (
patch(
"app.agent.middleware.activity_log.query_activity_logs",
side_effect=RuntimeError(f"OPENAI_API_KEY={secret_marker}"),
),
patch("app.agent.middleware.activity_log.logger", mock_logger),
):
result = asyncio.run(
middleware._tool_provider.query_activity_log(keyword="visible")
)
assert secret_marker not in result
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in result
assert "***" in str(mock_logger.method_calls)
def test_factory_does_not_register_activity_log_tool():
"""活动日志查询工具应由中间件注册,不应进入全局工具工厂。"""
with patch(
"app.agent.tools.factory._get_plugin_agent_tools",
return_value=[],
):
tools = MoviePilotToolFactory.create_tools(
session_id="activity-session",
user_id="10001",
)
tool_names = {tool.name for tool in tools}
assert QUERY_ACTIVITY_LOG_TOOL_NAME not in tool_names