import json from types import SimpleNamespace 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 ( 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_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", "explanation": "测试加载技能", } }, ) 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", "explanation": "测试加载技能", }, } ]