- app/services/plugins.py + migration 35 : table plugins (slug, name,
description, enabled) pré-remplie avec 3 modules câblés — web-tools,
web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
(aucun router touché) → toutes les routes /workspace/automations* refusées +
garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
« Extensions » / « Automations » en {% if %} (absentes du DOM), sections
conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
(slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
/api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
128 lines
5.1 KiB
Python
128 lines
5.1 KiB
Python
"""v7.51.0 — Menu + de l'assistant : sections, parcours fichiers/répertoires,
|
||
jeton ``folder:<id>`` résolu par ContextBuilder."""
|
||
|
||
from pathlib import Path
|
||
|
||
from app.db import get_conn
|
||
from app.services.context_builder import ContextBuilder
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||
|
||
|
||
def _uid() -> int:
|
||
with get_conn() as conn:
|
||
row = conn.execute("SELECT id FROM users LIMIT 1").fetchone()
|
||
return row["id"] if row else 1
|
||
|
||
|
||
def _folder_with_children(n_children: int = 2, body: str = "Contenu enfant.") -> int:
|
||
with get_conn() as conn:
|
||
cur = conn.execute(
|
||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||
"VALUES ('ws', 'Dossier Specs', '', 'blocks', 'Workspace')"
|
||
)
|
||
fid = cur.lastrowid
|
||
for i in range(n_children):
|
||
conn.execute(
|
||
"INSERT INTO pages (workspace, title, content, content_format, "
|
||
"parent_section, parent_id) VALUES ('ws', ?, ?, 'markdown', 'Private', ?)",
|
||
(f"Spec {i}", body * 10 if i else body, fid),
|
||
)
|
||
conn.commit()
|
||
return fid
|
||
|
||
|
||
def test_folder_mention_resolves_children(client):
|
||
"""folder:<id> = titre du dossier + contenu de ses documents directs."""
|
||
fid = _folder_with_children()
|
||
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
|
||
assert "Dossier Specs" in ctx
|
||
assert "Contenu enfant." in ctx
|
||
|
||
|
||
def test_folder_mention_truncates_on_budget(client):
|
||
"""Le contenu d'un dossier est borné (~12k) : un gros dossier n'explose pas le prompt."""
|
||
fid = _folder_with_children(n_children=12, body="X" * 800)
|
||
ctx = ContextBuilder(_uid()).build(mentions=[f"folder:{fid}"])
|
||
assert "tronqué" in ctx
|
||
assert len(ctx) < 20000
|
||
|
||
|
||
def test_folder_mention_unknown_id(client):
|
||
ctx = ContextBuilder(_uid()).build(mentions=["folder:999999"])
|
||
assert "introuvable" in ctx
|
||
|
||
|
||
def test_browse_contract_nav_menu(client):
|
||
"""Contrat que le parcours du menu + consomme : dossier = icon 'folder',
|
||
un niveau par appel via parent_id."""
|
||
with get_conn() as conn:
|
||
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
|
||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('BrowseWS', 1)")
|
||
ws_id = cur.lastrowid
|
||
cur = conn.execute(
|
||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Dossier A', '', 'blocks', "
|
||
"'Workspace', NULL)",
|
||
(ws_id,),
|
||
)
|
||
folder_id = cur.lastrowid
|
||
conn.execute(
|
||
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
|
||
"parent_section, parent_id) VALUES ('BrowseWS', ?, 'Note enfant', '', 'markdown', "
|
||
"'Private', ?)",
|
||
(ws_id, folder_id),
|
||
)
|
||
conn.commit()
|
||
|
||
root = client.get(f"/api/nav/menu?workspace_id={ws_id}").json()["items"]
|
||
names = {i["name"]: i for i in root}
|
||
assert names["Dossier A"]["icon"] == "folder"
|
||
|
||
kids = client.get(f"/api/nav/menu?workspace_id={ws_id}&parent_id={folder_id}").json()["items"]
|
||
kid = {i["name"]: i for i in kids}["Note enfant"]
|
||
assert kid["icon"] != "folder"
|
||
assert kid["url"] == f"/pages/{kid['id']}"
|
||
|
||
|
||
def test_agent_panel_renders_plus_menu(client):
|
||
"""La page rend bien le menu + (markup + bouton)."""
|
||
resp = client.get("/accounts")
|
||
assert resp.status_code == 200
|
||
html = resp.text
|
||
assert "fd-ap-plus-menu" in html
|
||
assert "fd-ap-add" in html
|
||
assert "Menu d'ajout au contexte" in html
|
||
|
||
|
||
def test_plus_menu_sections_and_disabled_states():
|
||
"""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 "action:'files'" in root and "action:'skills'" in root
|
||
# v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
|
||
assert root.count("disabled:true") == 0
|
||
|
||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||
assert "action:'search'" in files and "action:'browse'" in files
|
||
assert "folder:" in src and "document:" in src
|
||
|
||
|
||
def test_folder_chip_token_reaches_run_payload():
|
||
"""Le jeton folder:<id> épinglé part dans payload.mentions (contrat front→agent)."""
|
||
src = JS_PATH.read_text(encoding="utf-8")
|
||
# composerMentions() pioche dans contextChips[].token : le parcours doit
|
||
# produire des items avec token folder:/document:.
|
||
assert "token: 'folder:' + cur.id" in src
|
||
assert "token: (isFolder ? 'folder:' : 'document:') + x.id" in src
|
||
# ... et le backend sait les résoudre.
|
||
cb = (ROOT / "app" / "services" / "context_builder.py").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
assert 'm.startswith("folder:")' in cb
|
||
assert "def _single_folder" in cb
|