- Bouton + : menu à sections (fichiers/répertoires, compétences-skills,
connecteurs, Design System – Canevas, Add plugins, Mémoire on/off).
Sections pas encore livrées affichées « bientôt (phase N) » mais désactivées,
navigation clavier ↑/↓/Entrée/Échap, focus visible, la frappe referme le menu.
- Parcours « Parcourir… » : un niveau par appel via GET /api/nav/menu
(contrat : dossier = icon 'folder'), fil d'Ariane cliquable, épingle de dossier
via la ligne « 📌 Épingler le dossier ».
- Jeton folder:<id> résolu par ContextBuilder._single_folder() : titre du dossier
+ documents directs, budget ~12k caractères (marqueur « tronqué »), enfants
directs seulement.
- « Rechercher… » conserve l'ancien sélecteur de mentions (@ inline + recherche
par nom) : régression zéro sur le chemin existant.
- Tests : tests/test_v751_plus_menu.py (7 tests). Suite complète 1273 verts
(-n auto), ruff check app tests propre, eslint static/js 0 erreur.
- Docs : CHANGELOG, ROADMAP (phase 1 cochée), docs/V74_Agent_Plus_Menu.md statut.
127 lines
5.0 KiB
Python
127 lines
5.0 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 ; les sections pas encore livrées sont
|
||
désactivées (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
|
||
|
||
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
|