POST /api/skills/{id}/markdown set sk.name = slugify(sk.name or match['name']),
taking the name parsed from the edited markdown frontmatter. A changed name
makes update_skill() move the skill directory on disk and re-key its usage
sidecar, orphaning the original id. The UI still holds that original id, so the
next DELETE /api/skills/{id} fails the name/id lookup and 404s — 'can't delete
them now'.
The audit save path (_apply_skill_md) already guards against exactly this with
sk.name = name and an explicit 'must NEVER rename the skill' comment. Apply the
same pin here: keep the stored name on markdown save (content edits still take
effect; only the rename is suppressed). Drops the now-unused slugify import.
Adds tests/test_skill_save_no_rename.py: saving markdown whose frontmatter
renames the skill keeps the original name and applies the edit, and a
subsequent delete-by-original-id succeeds. Pure unit test — calls the route
handlers directly with a mock Request (no server/network), like
test_skills_delete_owner.py.
Co-authored-by: lalalune <shawgotbags@gmail.com>
This commit is contained in:
@@ -1442,7 +1442,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
@router.post("/{skill_id}/markdown")
|
@router.post("/{skill_id}/markdown")
|
||||||
async def save_skill_markdown(request: Request, skill_id: str):
|
async def save_skill_markdown(request: Request, skill_id: str):
|
||||||
"""Replace SKILL.md with new raw content. Parses + validates first."""
|
"""Replace SKILL.md with new raw content. Parses + validates first."""
|
||||||
from services.memory.skill_format import Skill, slugify
|
from services.memory.skill_format import Skill
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
new_content = body.get("markdown")
|
new_content = body.get("markdown")
|
||||||
@@ -1457,7 +1457,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
sk = Skill.from_markdown(new_content)
|
sk = Skill.from_markdown(new_content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(400, f"Could not parse SKILL.md: {e}")
|
raise HTTPException(400, f"Could not parse SKILL.md: {e}")
|
||||||
sk.name = slugify(sk.name or match.get("name"))
|
# Never rename on save: a changed `name` in the markdown would move
|
||||||
|
# the skill dir (update_skill) and orphan the original id, so a later
|
||||||
|
# delete 404s (#1333). Pin to the stored name, like _apply_skill_md.
|
||||||
|
sk.name = match.get("name")
|
||||||
if not sk.owner:
|
if not sk.owner:
|
||||||
sk.owner = match.get("owner") or user
|
sk.owner = match.get("owner") or user
|
||||||
ok = skills_manager.update_skill(match.get("name"), {
|
ok = skills_manager.update_skill(match.get("name"), {
|
||||||
|
|||||||
120
tests/test_skill_save_no_rename.py
Normal file
120
tests/test_skill_save_no_rename.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Saving a skill's markdown must NOT rename it (issue #1333: can't delete skills).
|
||||||
|
|
||||||
|
`save_skill_markdown` (POST /api/skills/{id}/markdown) parsed the new markdown
|
||||||
|
and set `sk.name = slugify(sk.name or match["name"])` — so editing the frontmatter
|
||||||
|
`name:` silently renamed the skill, which moves its directory on disk
|
||||||
|
(`update_skill`) and orphans the original id. A later DELETE by the id the UI
|
||||||
|
still holds then 404s ("can't delete them now"). The audit save path
|
||||||
|
(`_apply_skill_md`) already pins the name with the comment that a save must
|
||||||
|
NEVER rename; this locks that same guarantee for the markdown-save endpoint.
|
||||||
|
|
||||||
|
Pure unit test: calls the route handlers directly with a mock Request (no
|
||||||
|
server, network, or browser), mirroring tests/test_skills_delete_owner.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.datastructures import State
|
||||||
|
|
||||||
|
from services.memory.skills import SkillsManager
|
||||||
|
from services.memory.skill_format import slugify
|
||||||
|
from routes.skills_routes import setup_skills_routes
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill_md(skills_root: Path, category: str, name: str, owner: str) -> Path:
|
||||||
|
skill_dir = skills_root / slugify(category or "general", fallback="general") / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
md = textwrap.dedent(f"""\
|
||||||
|
---
|
||||||
|
name: {name}
|
||||||
|
description: original description
|
||||||
|
version: 1.0.0
|
||||||
|
category: {category}
|
||||||
|
tags: []
|
||||||
|
status: draft
|
||||||
|
confidence: 0.8
|
||||||
|
source: learned
|
||||||
|
owner: {owner}
|
||||||
|
created: 2026-01-01T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# When to use
|
||||||
|
test
|
||||||
|
|
||||||
|
# Procedure
|
||||||
|
- step 1
|
||||||
|
""")
|
||||||
|
path = skill_dir / "SKILL.md"
|
||||||
|
path.write_text(md, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _md_named(name: str) -> str:
|
||||||
|
return textwrap.dedent(f"""\
|
||||||
|
---
|
||||||
|
name: {name}
|
||||||
|
description: edited description
|
||||||
|
version: 1.0.0
|
||||||
|
category: general
|
||||||
|
tags: []
|
||||||
|
status: draft
|
||||||
|
confidence: 0.8
|
||||||
|
source: learned
|
||||||
|
owner: alice
|
||||||
|
created: 2026-01-01T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# When to use
|
||||||
|
edited
|
||||||
|
|
||||||
|
# Procedure
|
||||||
|
- step 1
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _request(user: str, body: dict | None = None) -> Request:
|
||||||
|
scope = {"type": "http", "app": type("App", (), {"state": State()})(),
|
||||||
|
"state": {"current_user": user}, "headers": []}
|
||||||
|
if body is None:
|
||||||
|
return Request(scope=scope)
|
||||||
|
|
||||||
|
async def _receive():
|
||||||
|
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
|
||||||
|
|
||||||
|
return Request(scope=scope, receive=_receive)
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(router, path: str, method: str):
|
||||||
|
return next(r.endpoint for r in router.routes
|
||||||
|
if r.path == path and method in r.methods)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_markdown_save_does_not_rename_then_delete_works(tmp_path):
|
||||||
|
skills_root = tmp_path / "skills"
|
||||||
|
skills_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_skill_md(skills_root, "general", "test-skill", "alice")
|
||||||
|
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
router = setup_skills_routes(sm)
|
||||||
|
save = _handler(router, "/api/skills/{skill_id}/markdown", "POST")
|
||||||
|
delete = _handler(router, "/api/skills/{skill_id}", "DELETE")
|
||||||
|
|
||||||
|
# Save markdown whose frontmatter renames the skill. The save must keep the
|
||||||
|
# original name (no rename), so the returned name is unchanged.
|
||||||
|
res = await save(_request("alice", {"markdown": _md_named("renamed-skill")}), "test-skill")
|
||||||
|
assert res["name"] == "test-skill", f"save renamed the skill to {res.get('name')!r}"
|
||||||
|
|
||||||
|
# The skill still lives under its original id (the edit DID apply).
|
||||||
|
names = {s.get("name") for s in sm.load(owner="alice")}
|
||||||
|
assert names == {"test-skill"}, names
|
||||||
|
descriptions = {s.get("description") for s in sm.load(owner="alice")}
|
||||||
|
assert "edited description" in descriptions # the content edit took effect
|
||||||
|
|
||||||
|
# The crux of #1333: deleting by the original id now succeeds.
|
||||||
|
assert await delete(_request("alice"), "test-skill") == {"ok": True}
|
||||||
|
assert sm.load(owner="alice") == []
|
||||||
Reference in New Issue
Block a user