"""SKILL.md 解析器单元测试""" import os import tempfile import pytest from agentkit.skills.base import Skill, SkillConfig from agentkit.skills.loader import SkillLoader from agentkit.skills.registry import SkillRegistry from agentkit.skills.skill_md import SkillMdParser # ── 测试用 SKILL.md 内容 ────────────────────────────────── FULL_SKILL_MD = '''\ --- name: content-generator description: "Generate high-quality content based on requirements" agent_type: content_generation execution_mode: react intent: keywords: ["generate", "write", "content"] description: "Content generation tasks" examples: ["Write a blog post", "Generate marketing copy"] quality_gate: required_fields: ["content"] min_word_count: 100 max_retries: 3 custom_validator: "validators.check_quality" --- # Trigger - User asks to generate content - Keywords: generate, write, create content # Steps 1. Analyze the user's requirements and target audience 2. Research relevant topics and gather information 3. Draft the content following best practices 4. Review and refine the output # Pitfalls - Don't generate overly generic content - Avoid plagiarism by always creating original content - Don't ignore the target audience's preferences # Verification - Content meets minimum word count - Content is relevant to the user's request - Output format matches expectations ''' MINIMAL_SKILL_MD = '''\ --- name: minimal-skill description: "A minimal skill" agent_type: minimal --- # Steps 1. Do something ''' NO_FRONTMATTER_MD = '''\ # Steps 1. Step one 2. Step two ''' EMPTY_FRONTMATTER_MD = '''\ --- --- # Steps 1. Step one ''' def _write_skill_md(directory: str, filename: str, content: str) -> str: path = os.path.join(directory, filename) with open(path, "w", encoding="utf-8") as f: f.write(content) return path # ── SkillMdParser.parse 测试 ────────────────────────────── class TestSkillMdParserParse: """SkillMdParser.parse() 解析测试""" def test_parse_full_skill_md(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) assert frontmatter["name"] == "content-generator" assert frontmatter["description"] == "Generate high-quality content based on requirements" assert frontmatter["agent_type"] == "content_generation" assert frontmatter["execution_mode"] == "react" assert frontmatter["intent"]["keywords"] == ["generate", "write", "content"] assert frontmatter["quality_gate"]["required_fields"] == ["content"] assert frontmatter["quality_gate"]["min_word_count"] == 100 assert "trigger" in sections assert "steps" in sections assert "pitfalls" in sections assert "verification" in sections assert "Analyze the user's requirements" in sections["steps"] assert "Don't generate overly generic content" in sections["pitfalls"] assert "Trigger" not in body or "# Trigger" in body def test_parse_minimal_skill_md(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "minimal.md", MINIMAL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) assert frontmatter["name"] == "minimal-skill" assert frontmatter["description"] == "A minimal skill" assert "steps" in sections assert "Do something" in sections["steps"] def test_parse_no_frontmatter(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "no_fm.md", NO_FRONTMATTER_MD) frontmatter, sections, body = SkillMdParser.parse(path) assert frontmatter == {} assert "steps" in sections def test_parse_empty_frontmatter(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "empty_fm.md", EMPTY_FRONTMATTER_MD) frontmatter, sections, body = SkillMdParser.parse(path) assert frontmatter == {} assert "steps" in sections def test_parse_missing_sections_graceful(self): content = """\ --- name: no-sections description: "No body sections" agent_type: test --- """ with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "nosec.md", content) frontmatter, sections, body = SkillMdParser.parse(path) assert frontmatter["name"] == "no-sections" assert sections == {} # ── SkillMdParser.to_skill_config 测试 ──────────────────── class TestSkillMdToSkillConfig: """SkillMdParser.to_skill_config() 转换测试""" def test_to_skill_config_full(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "full.md", FULL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) config = SkillMdParser.to_skill_config(frontmatter, sections, path) assert config.name == "content-generator" assert config.agent_type == "content_generation" assert config.description == "Generate high-quality content based on requirements" assert config.execution_mode == "react" assert config.intent.keywords == ["generate", "write", "content"] assert config.intent.description == "Content generation tasks" assert config.intent.examples == ["Write a blog post", "Generate marketing copy"] assert config.quality_gate.required_fields == ["content"] assert config.quality_gate.min_word_count == 100 assert config.quality_gate.max_retries == 3 assert config.quality_gate.custom_validator == "validators.check_quality" assert config.prompt is not None assert "instructions" in config.prompt assert "constraints" in config.prompt assert "output_format" in config.prompt assert "context" in config.prompt def test_to_skill_config_level_0_summary_only(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "level0.md", FULL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) config = SkillMdParser.to_skill_config( frontmatter, sections, path, disclosure_level=0, ) assert config.name == "content-generator" assert config.description != "" assert config.disclosure_level == 1 # Level 0: prompt 仅含 identity(概要信息) assert config.prompt is not None assert "identity" in config.prompt assert "instructions" not in config.prompt def test_to_skill_config_level_1_full(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "level1.md", FULL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) config = SkillMdParser.to_skill_config( frontmatter, sections, path, disclosure_level=1, ) assert config.name == "content-generator" assert config.disclosure_level == 1 assert config.prompt is not None assert "instructions" in config.prompt def test_to_skill_config_minimal(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "minimal.md", MINIMAL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) config = SkillMdParser.to_skill_config(frontmatter, sections, path) assert config.name == "minimal-skill" assert config.agent_type == "minimal" assert config.execution_mode == "react" # 默认值 assert config.intent.keywords == [] assert config.quality_gate.required_fields == [] def test_to_skill_config_no_frontmatter(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "no_fm.md", NO_FRONTMATTER_MD) frontmatter, sections, body = SkillMdParser.parse(path) # 无 frontmatter 时 name 为空,无法创建有效的 SkillConfig # 验证解析结果正确即可 assert frontmatter == {} assert "steps" in sections def test_skill_md_path_stored(self): with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "path_test.md", FULL_SKILL_MD) frontmatter, sections, body = SkillMdParser.parse(path) config = SkillMdParser.to_skill_config(frontmatter, sections, path) assert config.skill_md_path == path # ── SkillConfig 新字段测试 ───────────────────────────────── class TestSkillConfigNewFields: """SkillConfig 新增 skill_md_path 和 disclosure_level 字段测试""" def test_default_skill_md_path_is_none(self): config = SkillConfig( name="test", agent_type="test", task_mode="llm_generate", prompt={"identity": "test"}, ) assert config.skill_md_path is None def test_default_disclosure_level_is_one(self): config = SkillConfig( name="test", agent_type="test", task_mode="llm_generate", prompt={"identity": "test"}, ) assert config.disclosure_level == 1 def test_skill_md_path_set(self): config = SkillConfig( name="test", agent_type="test", task_mode="llm_generate", prompt={"identity": "test"}, skill_md_path="/path/to/skill.md", ) assert config.skill_md_path == "/path/to/skill.md" def test_disclosure_level_set(self): config = SkillConfig( name="test", agent_type="test", task_mode="llm_generate", prompt={"identity": "test"}, disclosure_level=2, ) assert config.disclosure_level == 2 def test_to_dict_includes_new_fields(self): config = SkillConfig( name="test", agent_type="test", task_mode="llm_generate", prompt={"identity": "test"}, skill_md_path="/path/to/skill.md", disclosure_level=1, ) d = config.to_dict() assert d["skill_md_path"] == "/path/to/skill.md" assert d["disclosure_level"] == 1 def test_from_dict_includes_new_fields(self): data = { "name": "test", "agent_type": "test", "task_mode": "llm_generate", "prompt": {"identity": "test"}, "skill_md_path": "/path/to/skill.md", "disclosure_level": 2, } config = SkillConfig.from_dict(data) assert config.skill_md_path == "/path/to/skill.md" assert config.disclosure_level == 2 def test_from_dict_defaults_new_fields(self): data = { "name": "test", "agent_type": "test", "task_mode": "llm_generate", "prompt": {"identity": "test"}, } config = SkillConfig.from_dict(data) assert config.skill_md_path is None assert config.disclosure_level == 1 # ── SkillLoader.load_from_skill_md 测试 ─────────────────── class TestSkillLoaderFromSkillMd: """SkillLoader.load_from_skill_md() 加载测试""" def test_load_from_skill_md_creates_skill(self): registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD) skill = loader.load_from_skill_md(path) assert isinstance(skill, Skill) assert skill.name == "content-generator" assert skill.config.agent_type == "content_generation" assert skill.config.skill_md_path == path assert skill.config.disclosure_level == 1 # 默认 level=1 def test_load_from_skill_md_registers_in_registry(self): registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD) loader.load_from_skill_md(path) assert registry.has_skill("content-generator") def test_load_from_skill_md_level_0(self): registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD) skill = loader.load_from_skill_md(path, disclosure_level=0) assert skill.config.disclosure_level == 0 # Level 0: prompt 仅含 identity,不含 instructions assert skill.config.prompt is not None assert "identity" in skill.config.prompt assert "instructions" not in skill.config.prompt def test_load_from_skill_md_level_1(self): registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: path = _write_skill_md(tmpdir, "content.md", FULL_SKILL_MD) skill = loader.load_from_skill_md(path, disclosure_level=1) assert skill.config.disclosure_level == 1 assert skill.config.prompt is not None assert "instructions" in skill.config.prompt def test_load_from_directory_includes_md_files(self): registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: _write_skill_md(tmpdir, "skill.md", FULL_SKILL_MD) skills = loader.load_from_directory(tmpdir) assert len(skills) == 1 assert skills[0].name == "content-generator" def test_load_from_directory_mixed_yaml_and_md(self): import yaml registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: # YAML 文件 yaml_path = os.path.join(tmpdir, "yaml_skill.yaml") with open(yaml_path, "w", encoding="utf-8") as f: yaml.dump({ "name": "yaml_skill", "agent_type": "yaml", "task_mode": "llm_generate", "prompt": {"identity": "YAML 技能"}, }, f) # SKILL.md 文件 _write_skill_md(tmpdir, "md_skill.md", FULL_SKILL_MD) skills = loader.load_from_directory(tmpdir) assert len(skills) == 2 names = [s.name for s in skills] assert "yaml_skill" in names assert "content-generator" in names def test_load_from_directory_skips_invalid_md(self, caplog): registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) with tempfile.TemporaryDirectory() as tmpdir: # 无效的 MD(不是合法的 SKILL.md 格式,YAML 解析后缺少必要字段) invalid_md = "This is just plain text, not a valid SKILL.md at all." _write_skill_md(tmpdir, "invalid.md", invalid_md) with caplog.at_level("WARNING"): skills = loader.load_from_directory(tmpdir) # 无效文件应被跳过(纯文本无 frontmatter,name 为空) assert len(skills) == 0 # ── CLI skill create 测试 ───────────────────────────────── class TestCliSkillCreate: """CLI skill create 命令测试""" def test_create_generates_valid_skill_md(self): from typer.testing import CliRunner from agentkit.cli.skill import skill_app runner = CliRunner() with tempfile.TemporaryDirectory() as tmpdir: result = runner.invoke(skill_app, ["create", "my-skill", "--output-dir", tmpdir]) assert result.exit_code == 0 output_path = os.path.join(tmpdir, "my-skill.md") assert os.path.exists(output_path) # 验证生成的文件可以被解析 frontmatter, sections, body = SkillMdParser.parse(output_path) assert frontmatter["name"] == "my-skill" assert "steps" in sections assert "pitfalls" in sections assert "verification" in sections def test_create_template_is_loadable(self): from typer.testing import CliRunner from agentkit.cli.skill import skill_app runner = CliRunner() with tempfile.TemporaryDirectory() as tmpdir: runner.invoke(skill_app, ["create", "loadable-skill", "--output-dir", tmpdir]) output_path = os.path.join(tmpdir, "loadable-skill.md") registry = SkillRegistry() loader = SkillLoader(skill_registry=registry) skill = loader.load_from_skill_md(output_path) assert skill.name == "loadable-skill"