feat: menu + — Compétences-skills « Gérer » + « Parcourir » — phase 2/8 (v7.52.0)
- Sous-menu « Compétences-skills » : 11 skills builtin + skills enregistrés,
libellés affichés « Deep research » (/research) et « Skill-creator »
(/create-new-skill) via FD_SKILL_ALIAS (slugs intacts) ; le clic épingle le
skill au contexte, même chemin que la palette /.
- « Gérer les compétences » : section du menu + formulaire intégré (nom,
description, prompt) — création, édition, suppression (chip épinglé nettoyé),
export JSON téléchargé, import fichier avec overwrite (pas de doublon).
- « Parcourir les compétences » : section galerie filtrable (nom/description/
slug), installation en 1 clic (installGallerySkill réutilisé).
- PATCH /api/agent/skills/{skill_id} créé : édition partielle, whitelist de
colonnes, 400/401/404/409. OpenAPI régénéré : 515 chemins, version 7.52.0.
- En-tête ← + titre de section dans le menu (navigation à 3 niveaux).
- Fix : épingler un skill enregistré n'efface plus le message déjà tapé
(_pinDbSkill ne vide le composer que s'il est vide).
- Tests : tests/test_v752_skills_menu.py (7) + test_v751 adapté. Suite
complète 1280 verts (-n auto), ruff 0, eslint 0 problème.
This commit is contained in:
@@ -97,14 +97,15 @@ def test_agent_panel_renders_plus_menu(client):
|
||||
|
||||
|
||||
def test_plus_menu_sections_and_disabled_states():
|
||||
"""Les 6 sections du menu + existent ; les sections pas encore livrées sont
|
||||
désactivées (le menu ne promet rien que le code ne fait)."""
|
||||
"""Les 6 sections du menu + existent ; seules les sections déjà livrées sont
|
||||
actives (le menu ne promet rien que le code ne fait)."""
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
|
||||
assert f"key:{key}" in root, key
|
||||
assert root.count("disabled:true") == 5 # tout sauf « fichiers/répertoires »
|
||||
assert "action:'files'" in root
|
||||
assert "action:'files'" in root and "action:'skills'" in root
|
||||
# phases 1 + 2 livrées → il reste 4 sections « bientôt »
|
||||
assert root.count("disabled:true") == 4
|
||||
|
||||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'search'" in files and "action:'browse'" in files
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""v7.52.0 — Menu + : Compétences-skills (« Gérer » + « Parcourir »).
|
||||
|
||||
Couvre le nouveau ``PATCH /api/agent/skills/{id}``, l'import sans doublon et le
|
||||
câblage du sous-menu (sections, alias affichés, formulaire)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import anon_csrf
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
|
||||
|
||||
NEW_SKILL = {
|
||||
"name": "synthese-reunion",
|
||||
"description": "Synthèse de réunion",
|
||||
"prompt_template": "Résume cette réunion en décisions et actions.",
|
||||
}
|
||||
|
||||
|
||||
def _create(client, **overrides):
|
||||
body = {**NEW_SKILL, **overrides}
|
||||
r = client.post("/api/agent/skills", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_skill_patch_updates_fields(client):
|
||||
sid = _create(client)
|
||||
r = client.patch(f"/api/agent/skills/{sid}", json={
|
||||
"name": "synthese-v2", "description": "Version 2",
|
||||
"prompt_template": "Résume en 5 points.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "updated"
|
||||
|
||||
skills = {s["id"]: s for s in client.get("/api/agent/skills").json()["skills"]}
|
||||
assert skills[sid]["name"] == "synthese-v2"
|
||||
assert skills[sid]["description"] == "Version 2"
|
||||
assert skills[sid]["prompt_template"] == "Résume en 5 points."
|
||||
|
||||
|
||||
def test_skill_patch_partial_and_validation(client):
|
||||
sid = _create(client)
|
||||
# mise à jour partielle : un seul champ ne touche pas les autres
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={"description": "Seul champ"}).status_code == 200
|
||||
skills = {s["id"]: s for s in client.get("/api/agent/skills").json()["skills"]}
|
||||
assert skills[sid]["description"] == "Seul champ"
|
||||
assert skills[sid]["prompt_template"] == NEW_SKILL["prompt_template"]
|
||||
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={}).status_code == 400
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={"name": " "}).status_code == 400
|
||||
assert client.patch("/api/agent/skills/999999", json={"name": "ghost"}).status_code == 404
|
||||
|
||||
|
||||
def test_skill_patch_requires_session(client):
|
||||
sid = _create(client)
|
||||
anon_csrf(client) # CSRF valide mais plus de session → on veut le 401 de la route
|
||||
assert client.patch(f"/api/agent/skills/{sid}", json={"name": "pirate"}).status_code == 401
|
||||
|
||||
|
||||
def test_import_skill_has_no_duplicate(client):
|
||||
payload = {"name": "skill-importe", "prompt_template": "Fais X.", "description": "import"}
|
||||
first = client.post("/api/agent/skills/import", json={"payload": payload})
|
||||
assert first.status_code == 200 and first.json()["status"] == "imported"
|
||||
|
||||
# même payload, sans overwrite → 409 (pas de doublon silencieux)
|
||||
again = client.post("/api/agent/skills/import", json={"payload": payload})
|
||||
assert again.status_code == 409
|
||||
# avec overwrite → mise à jour de la même ligne
|
||||
over = client.post("/api/agent/skills/import", json={"payload": payload, "overwrite": True})
|
||||
assert over.status_code == 200 and over.json()["status"] == "updated"
|
||||
|
||||
names = [s["name"] for s in client.get("/api/agent/skills").json()["skills"]]
|
||||
assert names.count("skill-importe") == 1
|
||||
|
||||
|
||||
def test_export_then_roundtrip_import(client):
|
||||
sid = _create(client)
|
||||
doc = client.get(f"/api/agent/skills/{sid}/export").json()
|
||||
client.delete(f"/api/agent/skills/{sid}")
|
||||
back = client.post("/api/agent/skills/import", json={"payload": doc, "overwrite": True})
|
||||
assert back.status_code == 200, back.text
|
||||
assert back.json()["name"] == NEW_SKILL["name"]
|
||||
|
||||
|
||||
def test_skills_menu_sections_enabled(client):
|
||||
"""Sous-menu Compétences actif : alias affichés, Gérer + Parcourir, CRUD branché."""
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'skills'" in root
|
||||
assert "bientôt (phase 2)" not in root
|
||||
|
||||
alias = src.split("var FD_SKILL_ALIAS = ", 1)[1].split(";", 1)[0]
|
||||
assert "research" in alias and "Deep research" in alias
|
||||
assert "create-new-skill" in alias and "Skill-creator" in alias
|
||||
|
||||
for action in ("'skills-manage'", "'skills-gallery'", "'skill-new'",
|
||||
"'skill-edit'", "'skill-install'", "'skill-import'"):
|
||||
assert action in src, action
|
||||
for method in ("saveSkill()", "deleteSkill()", "exportSkill()", "importSkillFile"):
|
||||
assert method in src, method
|
||||
# PATCH utilisé pour l'édition (pas de POST qui recréerait la ligne)
|
||||
assert "method: f.id ? 'PATCH' : 'POST'" in src
|
||||
|
||||
|
||||
def test_skills_menu_markup_rendered(client):
|
||||
html = PANEL_HTML.read_text(encoding="utf-8")
|
||||
assert "fd-plus-form" in html
|
||||
assert 'x-ref="skillImport"' in html
|
||||
assert "Gérer les compétences" in JS_PATH.read_text(encoding="utf-8")
|
||||
resp = client.get("/accounts")
|
||||
assert resp.status_code == 200
|
||||
assert "fd-plus-form" in resp.text
|
||||
Reference in New Issue
Block a user