Files
MoviePilot/tests/test_agent_skills_middleware.py
2026-08-12 10:48:14 +08:00

231 lines
7.4 KiB
Python

import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import SystemMessage
from app.agent.middleware.skills import (
MAX_SKILL_RESULT_CHARS,
SKILL_TOOL_NAME,
SkillsMiddleware,
_alist_skills,
)
from app.agent.tools.tags import ToolTag
@pytest.fixture
def anyio_backend():
"""使用 asyncio 后端运行 anyio 异步测试。"""
return "asyncio"
def _write_skill(root, skill_id: str, name: str | None = None) -> None:
"""写入测试用 Skill 文件。"""
skill_dir = root / skill_id
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
f"""---
name: {name or skill_id}
description: test skill {skill_id}
allowed-tools: "read_file execute_command"
---
# {skill_id}
Use this skill carefully.
""",
encoding="utf-8",
)
@pytest.mark.anyio
async def test_alist_skills_sorts_skill_directories_by_name(tmp_path):
"""异步扫描技能目录时应按目录名稳定排序。"""
for skill_id in ("z-skill", "a-skill", "m-skill"):
_write_skill(tmp_path, skill_id)
skills = await _alist_skills(AsyncPath(str(tmp_path)))
assert ["a-skill", "m-skill", "z-skill"] == [
skill["id"] for skill in skills
]
def test_skills_middleware_exposes_skill_tool(tmp_path):
"""SkillsMiddleware 应以中间件工具形式暴露 skill。"""
_write_skill(tmp_path, "moviepilot-cli")
middleware = SkillsMiddleware(sources=[str(tmp_path)])
assert [tool.name for tool in middleware.tools] == [SKILL_TOOL_NAME]
assert ToolTag.Read in middleware.tools[0].tags
assert ToolTag.Skill in middleware.tools[0].tags
assert "moviepilot-cli" in middleware.tools[0].description
@pytest.mark.anyio
async def test_skill_tool_loads_skill_by_id_and_name(tmp_path):
"""skill 工具应支持按 id 或 name 加载完整 SKILL.md。"""
_write_skill(tmp_path, "moviepilot-cli", name="MoviePilot CLI")
middleware = SkillsMiddleware(sources=[str(tmp_path)])
skill_tool = middleware.tools[0]
by_id = json.loads(await skill_tool.ainvoke({"name": "moviepilot-cli"}))
by_name = json.loads(await skill_tool.ainvoke({"name": "MoviePilot CLI"}))
assert by_id["success"] is True
assert by_id["skill"]["id"] == "moviepilot-cli"
assert "# moviepilot-cli" in by_id["content"]
assert by_name["success"] is True
assert by_name["skill"]["name"] == "MoviePilot CLI"
@pytest.mark.anyio
async def test_skill_tool_caps_large_result_before_model_context(tmp_path):
"""超大 Skill 内容应在工具返回前限制到模型上下文上限。"""
_write_skill(tmp_path, "large-skill")
skill_path = tmp_path / "large-skill" / "SKILL.md"
with skill_path.open("a", encoding="utf-8") as file_handle:
file_handle.write("\n" + ("large-line\n" * 30000))
middleware = SkillsMiddleware(sources=[str(tmp_path)])
result = await middleware.tools[0].ainvoke({"name": "large-skill"})
payload = json.loads(result)
assert len(result) <= MAX_SKILL_RESULT_CHARS
assert payload["success"] is True
assert payload["truncated"] is True
assert "Skill 内容已截断" in payload["content"]
@pytest.mark.anyio
async def test_skill_tool_returns_not_found_for_unknown_skill(tmp_path):
"""skill 工具找不到技能时应返回结构化失败信息。"""
middleware = SkillsMiddleware(sources=[str(tmp_path)])
skill_tool = middleware.tools[0]
result = json.loads(await skill_tool.ainvoke({"name": "missing-skill"}))
assert result["success"] is False
assert "missing-skill" in result["message"]
def test_modify_request_instructs_model_to_use_skill_tool_without_paths(tmp_path):
"""系统提示应要求通过 skill 工具加载,而不是直接暴露文件读取路径。"""
_write_skill(tmp_path, "moviepilot-cli")
middleware = SkillsMiddleware(sources=[str(tmp_path)])
skills_metadata = middleware._load_skills_metadata()
request = ModelRequest(
model=None,
messages=[],
system_message=SystemMessage(content="BASE"),
state={"skills_metadata": skills_metadata},
runtime=None,
)
modified = middleware.modify_request(request)
system_content = str(modified.system_message.content)
assert "`skill` tool" in system_content
assert "moviepilot-cli" in system_content
assert "Read `" not in system_content
assert str(tmp_path) not in system_content
@pytest.mark.anyio
async def test_skill_tool_call_records_streaming_summary(tmp_path):
"""skill 工具执行时应记录流式聚合摘要。"""
_write_skill(tmp_path, "moviepilot-cli")
calls = []
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=lambda **kwargs: calls.append(kwargs),
)
middleware = SkillsMiddleware(
sources=[str(tmp_path)],
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SKILL_TOOL_NAME),
tool_call={
"args": {
"name": "moviepilot-cli",
}
},
)
async def _fake_handler(_request):
"""返回模拟工具结果。"""
return "ok"
result = await middleware.awrap_tool_call(request, _fake_handler)
assert result == "ok"
assert calls == [
{
"tool_name": SKILL_TOOL_NAME,
"tool_message": "Skill loaded",
"tool_kwargs": {
"name": "moviepilot-cli",
},
}
]
@pytest.mark.anyio
async def test_skill_middleware_sanitizes_its_own_logs(tmp_path):
"""Skill 中间件读取参数和异常写日志时必须脱敏。"""
secret_marker = "skill-secret-marker-2471"
stream_handler = SimpleNamespace(
is_streaming=True,
record_tool_call=MagicMock(),
)
middleware = SkillsMiddleware(
sources=[str(tmp_path)],
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SKILL_TOOL_NAME),
tool_call={"args": {"name": f"api_key={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with (
patch("app.agent.middleware.skills.logger", mock_logger),
pytest.raises(RuntimeError),
):
await middleware.awrap_tool_call(request, _failing_handler)
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
@pytest.mark.anyio
async def test_skill_provider_error_does_not_echo_secret(tmp_path):
"""Skill provider 内部捕获的异常不能进入日志或模型错误结果。"""
secret_marker = "skill-provider-secret-6518"
middleware = SkillsMiddleware(sources=[str(tmp_path)])
mock_logger = MagicMock()
with (
patch.object(
middleware._skill_provider,
"_find_skill",
new=AsyncMock(
side_effect=RuntimeError(f"DATABASE_PASSWORD={secret_marker}")
),
),
patch("app.agent.middleware.skills.logger", mock_logger),
):
result = await middleware._skill_provider.load_skill("visible-skill")
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)