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

356 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证 Agent 记忆文件的用户隔离、主文件加载及按需检索。
"""
import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from langchain_core.messages import SystemMessage
from app.agent.middleware.memory import (
SEARCH_MEMORY_TOOL_NAME,
MemoryMiddleware,
query_memory_files,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.tags import ToolTag
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",
)
def test_memory_loads_only_primary_file(tmp_path):
"""
每轮默认上下文只能装载 MEMORY.md,不应装载主题或活动正文。
"""
primary = tmp_path / "MEMORY.md"
primary.write_text("用户偏好:简洁回复。", encoding="utf-8")
(tmp_path / "MEDIA_RULES.md").write_text("主题记忆:优先 Remux。", encoding="utf-8")
_write_activity_log(
tmp_path / "activity",
datetime.now().strftime("%Y-%m-%d"),
["- **10:00** 活动正文不应自动进入上下文"],
)
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
)
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None, config=None))
request = SimpleNamespace(
state=state_update,
system_message=SystemMessage(content="SYSTEM"),
override=lambda **kwargs: SimpleNamespace(
state=state_update,
system_message=kwargs["system_message"],
),
)
modified = middleware.modify_request(request)
system_text = str(modified.system_message.content)
assert state_update["memory_contents"] == {str(primary): "用户偏好:简洁回复。"}
assert "用户偏好:简洁回复" in system_text
assert "主题记忆:优先 Remux" not in system_text
assert "活动正文不应自动进入上下文" not in system_text
assert "search_memory" in system_text
assert "session_search" in system_text
def test_memory_loads_global_and_current_user_primary_files_only(tmp_path):
"""
默认上下文应按全局再当前用户顺序加载,且不触及其他用户记忆。
"""
global_memory = tmp_path / "MEMORY.md"
global_memory.write_text("公共规则:使用简洁风格。", encoding="utf-8")
user_memory_dir = tmp_path / "users" / "user-a"
user_memory = user_memory_dir / "MEMORY.md"
user_memory.parent.mkdir(parents=True)
user_memory.write_text("用户偏好:优先中文回答。", encoding="utf-8")
other_memory = tmp_path / "users" / "user-b" / "MEMORY.md"
other_memory.parent.mkdir(parents=True)
other_memory.write_text("其他用户的私密偏好。", encoding="utf-8")
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
user_memory_dir=str(user_memory_dir),
)
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None, config=None))
prompt = middleware._format_agent_memory(
state_update["memory_contents"],
memory_empty=state_update["memory_empty"],
)
assert state_update["memory_contents"] == {
str(global_memory): "公共规则:使用简洁风格。",
str(user_memory): "用户偏好:优先中文回答。",
}
assert prompt.index("公共规则:使用简洁风格") < prompt.index("用户偏好:优先中文回答")
assert "其他用户的私密偏好" not in prompt
assert str(user_memory) in prompt
def test_memory_onboarding_exposes_relevant_retrieval(tmp_path):
"""
主记忆为空时仍提供按需主题与历史证据检索。
"""
(tmp_path / "MEDIA_RULES.md").write_text("主题记忆:偏好 HEVC。", encoding="utf-8")
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None, config=None))
prompt = middleware._format_agent_memory(
state_update["memory_contents"],
memory_empty=state_update["memory_empty"],
)
assert "No primary durable memory is saved" in prompt
assert "session_search" in prompt
assert "search_memory" in prompt
assert "偏好 HEVC" not in prompt
def test_query_memory_files_excludes_obsolete_activity_but_preserves_files(tmp_path):
"""
旧活动文件原地保留,但所有分类与显式路径都不能把摘要当历史证据。
"""
(tmp_path / "MEDIA_RULES.md").write_text("优先 Remux", encoding="utf-8")
_write_activity_log(tmp_path / "activity", "2026-06-18", ["- **10:00** 旧摘要"])
payload = query_memory_files(str(tmp_path))
assert [entry["text"] for entry in payload["entries"]] == ["优先 Remux"]
assert query_memory_files(str(tmp_path), category="activity")["success"] is False
old_file = tmp_path / "activity" / "2026-06-18.md"
assert query_memory_files(str(tmp_path), file_path=str(old_file))["success"] is False
assert old_file.exists()
def test_query_memory_files_scopes_global_and_current_user_memory(tmp_path):
"""
检索只能看到公共记忆与当前用户记忆,不能跨用户读取。
"""
(tmp_path / "PUBLIC_RULES.md").write_text("公共主题:优先 Remux。\n", encoding="utf-8")
user_memory_dir = tmp_path / "users" / "user-a"
(user_memory_dir / "USER_RULES.md").parent.mkdir(parents=True)
(user_memory_dir / "USER_RULES.md").write_text("当前用户主题:偏好 HEVC。\n", encoding="utf-8")
other_memory = tmp_path / "users" / "user-b" / "USER_RULES.md"
other_memory.parent.mkdir(parents=True)
other_memory.write_text("其他用户主题:偏好 AV1。\n", encoding="utf-8")
payload = query_memory_files(
str(tmp_path),
user_memory_dir=str(user_memory_dir),
query="偏好",
category="topic",
limit=10,
)
other_file = query_memory_files(
str(tmp_path),
user_memory_dir=str(user_memory_dir),
file_path=str(other_memory),
)
assert payload["success"] is True
assert {entry["text"] for entry in payload["entries"]} == {
"当前用户主题:偏好 HEVC。",
}
assert all(entry["scope"] == "user" for entry in payload["entries"])
assert other_file["success"] is False
def test_query_memory_files_supports_regex_and_bounds_results(tmp_path):
"""
记忆检索应支持显式正则,并对返回条数做有界处理。
"""
(tmp_path / "one.md").write_text("整理电影 A\n查询站点\n", encoding="utf-8")
(tmp_path / "two.md").write_text("整理电影 B\n", encoding="utf-8")
payload = query_memory_files(
str(tmp_path),
query="整理|站点",
category="topic",
use_regex=True,
limit=1,
)
invalid = query_memory_files(
str(tmp_path),
query="[",
category="topic",
use_regex=True,
)
assert payload["success"] is True
assert payload["total_count"] == 3
assert payload["returned_count"] == 1
assert payload["truncated"] is True
assert invalid["success"] is False
assert "无效的记忆检索正则表达式" in invalid["message"]
def test_memory_middleware_exposes_search_tool(tmp_path):
"""
统一记忆中间件应通过一个只读系统工具提供按需检索。
"""
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
assert [tool.name for tool in middleware.tools] == [SEARCH_MEMORY_TOOL_NAME]
assert ToolTag.Read in middleware.tools[0].tags
assert ToolTag.System in middleware.tools[0].tags
assert middleware.tools[0]._agent_tool_source == "middleware:memory"
assert "主题记忆" in middleware.tools[0].description
def test_memory_search_tool_returns_json_payload(tmp_path):
"""
search_memory 工具应返回结构化 JSON,而不是将文件正文隐式注入上下文。
"""
(tmp_path / "MEDIA_RULES.md").write_text("优先 Remux。\n", encoding="utf-8")
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
result = asyncio.run(
middleware.tools[0].ainvoke({"query": "Remux", "category": "topic", "limit": 5})
)
payload = json.loads(result)
assert payload["success"] is True
assert payload["returned_count"] == 1
assert payload["entries"][0]["text"] == "优先 Remux。"
def test_memory_search_tool_reports_streaming_execution(tmp_path):
"""
search_memory 工具执行时应复用统一的工具显示策略。
"""
async def _run_test():
calls = []
stream_handler = SimpleNamespace(
is_streaming=True,
report_tool_call=lambda **kwargs: calls.append(kwargs) or "tool-1",
tool_call_finished=MagicMock(),
)
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME),
tool_call={"args": {"query": "整理", "category": "topic"}},
)
async def _fake_handler(_request):
"""
返回模拟工具结果。
"""
return "ok"
result = await middleware.awrap_tool_call(request, _fake_handler)
return result, calls, stream_handler.tool_call_finished
result, calls, finished = asyncio.run(_run_test())
assert result == "ok"
assert calls == [
{
"tool_name": SEARCH_MEMORY_TOOL_NAME,
"tool_message": '检索记忆,主要参数:{"query": "整理", "category": "topic"}',
"tool_kwargs": {"query": "整理", "category": "topic"},
}
]
finished.assert_called_once_with("tool-1", "done")
def test_memory_middleware_sanitizes_its_own_logs(tmp_path):
"""
记忆中间件读取参数和异常写日志时必须脱敏。
"""
async def _run_test():
secret_marker = "memory-secret-marker-6825"
stream_handler = SimpleNamespace(is_streaming=True, report_tool_call=MagicMock())
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME),
tool_call={"args": {"query": f"token={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with patch("app.agent.middleware.memory.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_memory_provider_error_does_not_echo_secret(tmp_path):
"""
记忆 provider 内部异常不能进入日志或模型错误结果。
"""
secret_marker = "memory-provider-secret-3584"
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
mock_logger = MagicMock()
with (
patch(
"app.agent.middleware.memory.query_memory_files",
side_effect=RuntimeError(f"OPENAI_API_KEY={secret_marker}"),
),
patch("app.agent.middleware.memory.logger", mock_logger),
):
result = asyncio.run(
middleware._tool_provider.search_memory(query="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_memory_has_no_activity_summary_hooks(tmp_path):
"""
稳定记忆中间件不再生成或清理逐轮活动摘要。
"""
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
assert "aafter_agent" not in MemoryMiddleware.__dict__
assert not hasattr(middleware, "_record_activity")
assert not hasattr(middleware, "_background_tasks")
assert "activity" not in middleware.tools[0].args_schema.model_json_schema()["properties"]["category"]["enum"]
def test_factory_does_not_register_memory_tool():
"""
记忆检索工具由统一中间件注册,不应进入全局工具工厂。
"""
with patch(
"app.agent.tools.factory._get_plugin_agent_tools",
return_value=[],
):
tools = MoviePilotToolFactory.create_tools(
session_id="memory-session",
user_id="10001",
)
assert SEARCH_MEMORY_TOOL_NAME not in {tool.name for tool in tools}