- Table agent_connectors (migration 32) : colonnes plates name, url, secret_encrypted, enabled, status, detail — config_json remis (YAGNI, les scopes OAuth des phases 6-7 ajouteront le leur). - app/services/connectors.py : 1 fichier au lieu du package connectors/ — 3 natifs (gitea, github, web) servis à la volée avec statut sans réseau, CRUD des personnels (URL validée par _validate_url = garde SSRF, clé chiffrée Fernet et jamais renvoyée — seul has_secret), probe() qui persiste status/detail, connector_fetch() borné à 20 000 car. - API /api/agent/connectors : GET, POST (400 URL privée), PATCH, DELETE, POST /connectors/probe — 401 sans session, CSRF global. OpenAPI 519 chemins. - Outil LLM connector_fetch (26e outil) : un seul outil qui dispatche vers Gitea/GitHub/web/personnalisé (id ou nom ou kind + path + query) au lieu d'un outil par connecteur ; désactivé/inconnu = erreur outil, jamais de run cassé. - Menu + : section « Connecteurs » (catalogue avec badge ✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs), formulaire « Ajouter un connecteur personnalisé » (clé en type=password). - Tests : tests/test_v755_connectors.py (13) — 3 natifs, roundtrip Fernet de la clé, 5 URLs refusées (localhost, 127.0.0.1, metadata cloud, ftp:, file:), toggle persisté, 401, probe OK/erreur, outil complet, câblage menu ; test_v751/v753/v754 adaptés (1 seule section « bientôt » = plugins). Suite complète 1306 verts (-n auto), ruff 0, eslint 0 problème.
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
|
||
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
|
||
assert root.count("disabled:true") == 1
|
||
|
||
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
|