diff --git a/services/memory/skill_format.py b/services/memory/skill_format.py index 633f4bec5..56a30a919 100644 --- a/services/memory/skill_format.py +++ b/services/memory/skill_format.py @@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE) _FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$") -def _parse_scalar(raw: str) -> Any: +def _parse_scalar(raw: str, *, string_only: bool = False) -> Any: raw = raw.strip() if raw == "": return "" + # String fields keep their spelling; quoted values still use the decoder + # below so existing escapes round-trip as before. + if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')): + return raw if raw.startswith("[") and raw.endswith("]"): inner = raw[1:-1].strip() if not inner: @@ -171,7 +175,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]: pending_key = key fm[key] = [] else: - fm[key] = _parse_scalar(val) + fm[key] = _parse_scalar(val, string_only=(key == "version")) pending_key = None continue m2 = _FM_BLOCK_LIST_RE.match(line) diff --git a/tests/test_skill_version_preservation.py b/tests/test_skill_version_preservation.py new file mode 100644 index 000000000..d3d9b8673 --- /dev/null +++ b/tests/test_skill_version_preservation.py @@ -0,0 +1,59 @@ +"""Issue #2131: editing a skill must not reinterpret its version as a number.""" + +import pytest + +from services.memory.skill_format import Skill, parse_frontmatter +from services.memory.skills import SkillsManager + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("1.10", "1.10"), + ("2.20", "2.20"), + ("01", "01"), + ("0", "0"), + ("1.0.0", "1.0.0"), + ("true", "true"), + ("null", "null"), + ('"1.10"', "1.10"), + ("'2.20'", "2.20"), + ('"release\\u002d01"', "release-01"), + ], +) +def test_version_survives_repeated_markdown_saves(raw, expected): + markdown = f"---\nname: demo\nversion: {raw}\nconfidence: 0.80\n---\n" + for _ in range(3): + skill = Skill.from_markdown(markdown) + assert skill.version == expected + assert skill.confidence == 0.8 + markdown = skill.to_markdown() + + +def test_version_preservation_keeps_other_frontmatter_types(): + fm, _ = parse_frontmatter( + "---\nversion: 1.10\nconfidence: 0.80\nuses: 3\n" + "enabled: true\ntags: [one, two]\n---\n" + ) + assert fm == { + "version": "1.10", "confidence": 0.8, "uses": 3, + "enabled": True, "tags": ["one", "two"], + } + + +@pytest.mark.parametrize("version_line", ["", "version:\n", 'version: ""\n']) +def test_missing_or_empty_version_retains_default(version_line): + skill = Skill.from_markdown(f"---\nname: demo\n{version_line}---\n") + assert skill.version == "1.0.0" + + +def test_version_survives_unrelated_edits_on_disk(tmp_path): + manager = SkillsManager(str(tmp_path)) + manager.add_skill(name="demo", version="1.10", owner="alice") + for i in range(3): + assert manager.update_skill("demo", {"description": f"Edit {i}"}, owner="alice") + stored = manager.load(owner="alice") + assert stored[0]["version"] == "1.10" + assert stored[0]["description"] == f"Edit {i}" + markdown = next((tmp_path / "skills").rglob("SKILL.md")).read_text(encoding="utf-8") + assert "version: 1.10\n" in markdown