feat: v6.6.0 — Agent phase 5 : API publique agent (/api/v2/agents, run synchrone JSON) + marketplace skills (export/import portable + galerie de 6 presets, palette / du panneau) + webhooks agent.run.started/failed · 764 tests verts
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
"""FlowDeck — v6.6.0 : Agent phase 5 (API publique agent + skill marketplace).
|
||||
|
||||
Covers the Bearer+scopes wrappers under ``/api/v2/agents`` and
|
||||
``/api/v2/skills``, the portable skill export/import + gallery install, the
|
||||
internal (session) marketplace routes, ownership checks, and the agent run
|
||||
lifecycle webhooks (``agent.run.started`` / ``finished`` / ``failed``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import skill_gallery
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Fresh SQLite DB + offline (mock) LLM — no network, no shared state."""
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn, init_db
|
||||
from app.main import app
|
||||
from app.password_utils import hash_password
|
||||
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.llm_provider = "offline"
|
||||
settings.agent_max_iterations = 12
|
||||
settings.agent_max_tokens_budget = 500_000
|
||||
settings.agent_run_timeout_seconds = 30
|
||||
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "
|
||||
"VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password("test"),),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
yield TestClient(app)
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _token(client, login: str, scopes: str = "read,write") -> dict:
|
||||
"""Register an account (opens a session) → legacy token → scoped v2 token."""
|
||||
r = client.post("/auth/register", json={
|
||||
"email": f"{login}@test.dev", "password": "secret123", "name": login,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
legacy = client.post("/api/v1/token").json()["token"]
|
||||
r = client.post("/api/v2/tokens", json={"name": "phase5", "scopes": scopes},
|
||||
headers={"Authorization": f"Bearer {legacy}"})
|
||||
assert r.status_code == 200, r.text
|
||||
return {"Authorization": f"Bearer {r.json()['token']}"}
|
||||
|
||||
|
||||
# ── Auth & scopes ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_v2_agents_requires_bearer(client):
|
||||
r = client.get("/api/v2/agents")
|
||||
assert r.status_code == 401
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert r.json()["status"] == 401
|
||||
|
||||
|
||||
def test_v2_skills_rejects_bad_token(client):
|
||||
r = client.get("/api/v2/skills", headers={"Authorization": "Bearer nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_v2_agent_write_requires_write_scope(client):
|
||||
headers = _token(client, "readonly", scopes="read")
|
||||
assert client.get("/api/v2/agents", headers=headers).status_code == 200
|
||||
r = client.post("/api/v2/agents", json={"name": "Nope"}, headers=headers)
|
||||
assert r.status_code == 403
|
||||
assert "write" in r.json()["detail"]
|
||||
|
||||
|
||||
# ── Agents CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_v2_agents_crud(client):
|
||||
headers = _token(client, "agentcrud")
|
||||
|
||||
r = client.post("/api/v2/agents", json={
|
||||
"name": "Analyste", "description": "Synthèses", "model": "gpt-4o",
|
||||
"system_instructions": "Sois concis.", "scope": {"tools": ["search_workspace"]},
|
||||
}, headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
agent_id = r.json()["id"]
|
||||
scope = r.json()["agent"]["scope_json"]
|
||||
assert scope == {"tools": ["search_workspace"]} or scope == '{"tools": ["search_workspace"]}'
|
||||
|
||||
listed = client.get("/api/v2/agents", headers=headers).json()
|
||||
assert any(a["id"] == agent_id for a in listed["agents"])
|
||||
assert listed["total"] >= 1
|
||||
|
||||
got = client.get(f"/api/v2/agents/{agent_id}", headers=headers)
|
||||
assert got.status_code == 200
|
||||
assert got.json()["name"] == "Analyste"
|
||||
assert "password_hash" not in got.text
|
||||
|
||||
upd = client.put(f"/api/v2/agents/{agent_id}",
|
||||
json={"description": "V2", "approval_mode": "confirm"},
|
||||
headers=headers)
|
||||
assert upd.status_code == 200
|
||||
assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).json()["description"] == "V2"
|
||||
|
||||
assert client.get("/api/v2/agents/999999", headers=headers).status_code == 404
|
||||
assert client.delete(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_v2_agent_idempotency(client):
|
||||
headers = _token(client, "agentidem")
|
||||
idem = {"Idempotency-Key": "agent-create-1"}
|
||||
r1 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem})
|
||||
r2 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem})
|
||||
assert r1.status_code == 201
|
||||
assert r2.status_code == r1.status_code
|
||||
assert r1.json()["id"] == r2.json()["id"]
|
||||
with_id = [a for a in client.get("/api/v2/agents", headers=headers).json()["agents"]
|
||||
if a["name"] == "Idem"]
|
||||
assert len(with_id) == 1, "idempotent replay must not create a second agent"
|
||||
|
||||
|
||||
# ── Conversations, run & audit ──────────────────────────────────────────────
|
||||
|
||||
def test_v2_conversation_and_sync_run(client):
|
||||
headers = _token(client, "runner")
|
||||
|
||||
r = client.post("/api/v2/agents/conversations", json={"title": "Run test"},
|
||||
headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
conv_id = r.json()["id"]
|
||||
|
||||
bad = client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={}, headers=headers)
|
||||
assert bad.status_code == 400
|
||||
|
||||
r = client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={"message": "Crée une collection CRM"}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
run = r.json()
|
||||
assert run["conversation_id"] == conv_id
|
||||
assert run["status"] == "completed"
|
||||
assert run["final"], "offline mock must yield a final answer"
|
||||
assert isinstance(run["events"], list) and run["events"]
|
||||
|
||||
detail = client.get(f"/api/v2/agents/conversations/{conv_id}", headers=headers).json()
|
||||
roles = [m["role"] for m in detail["messages"]]
|
||||
assert "user" in roles and "assistant" in roles
|
||||
|
||||
# Audit journal + rollback surface exposed to integrations
|
||||
actions = client.get(f"/api/v2/agents/conversations/{conv_id}/actions",
|
||||
headers=headers)
|
||||
assert actions.status_code == 200
|
||||
assert isinstance(actions.json()["actions"], list)
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT action FROM api_audit_log WHERE resource_id=? ORDER BY id",
|
||||
(str(conv_id),),
|
||||
).fetchall()
|
||||
assert "agent.run" in [r_[0] for r_ in rows]
|
||||
|
||||
|
||||
def test_v2_conversation_ownership(client):
|
||||
alice = _token(client, "alice")
|
||||
bob = _token(client, "bob")
|
||||
|
||||
conv_id = client.post("/api/v2/agents/conversations", json={"title": "Privé"},
|
||||
headers=alice).json()["id"]
|
||||
|
||||
# Bob sees nothing of Alice's conversation (and gets 404, not 403 leakage).
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=bob).status_code == 404
|
||||
assert client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={"message": "hack"}, headers=bob).status_code == 404
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}/actions",
|
||||
headers=bob).status_code == 404
|
||||
assert client.delete(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=bob).status_code == 404
|
||||
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=alice).status_code == 200
|
||||
|
||||
|
||||
def test_v2_trigger_agent(client):
|
||||
headers = _token(client, "trigger")
|
||||
agent_id = client.post("/api/v2/agents", json={
|
||||
"name": "Déclenché", "system_instructions": "Crée un document de statut.",
|
||||
}, headers=headers).json()["id"]
|
||||
|
||||
r = client.post(f"/api/v2/agents/{agent_id}/trigger", json={}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload["agent_id"] == agent_id
|
||||
assert payload["conversation_id"] > 0
|
||||
assert payload["status"] == "completed"
|
||||
assert payload["final"]
|
||||
|
||||
|
||||
def test_v2_conversation_delete(client):
|
||||
headers = _token(client, "deleter")
|
||||
conv_id = client.post("/api/v2/agents/conversations", json={"title": "Bye"},
|
||||
headers=headers).json()["id"]
|
||||
assert client.delete(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/agents/conversations/{conv_id}",
|
||||
headers=headers).status_code == 404
|
||||
|
||||
|
||||
# ── Skill marketplace ───────────────────────────────────────────────────────
|
||||
|
||||
def test_v2_skills_crud_and_scopes(client):
|
||||
headers = _token(client, "skillcrud")
|
||||
|
||||
r = client.post("/api/v2/skills", json={
|
||||
"name": "Veille", "description": "Surveille un sujet",
|
||||
"prompt_template": "Cherche les infos sur le sujet et résume.",
|
||||
"allowed_tools": ["search_workspace"],
|
||||
}, headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
skill_id = r.json()["id"]
|
||||
|
||||
listed = client.get("/api/v2/skills", headers=headers).json()
|
||||
assert any(s["id"] == skill_id for s in listed["skills"])
|
||||
|
||||
got = client.get(f"/api/v2/skills/{skill_id}", headers=headers)
|
||||
assert got.status_code == 200
|
||||
tools = got.json()["allowed_tools_json"]
|
||||
assert tools == ["search_workspace"] or tools == '["search_workspace"]'
|
||||
|
||||
dup = client.post("/api/v2/skills", json={
|
||||
"name": "Veille", "prompt_template": "autre",
|
||||
}, headers=headers)
|
||||
assert dup.status_code == 409
|
||||
|
||||
assert client.delete(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 200
|
||||
assert client.get(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_v2_skill_export_import_roundtrip(client):
|
||||
headers = _token(client, "porter")
|
||||
|
||||
created = client.post("/api/v2/skills", json={
|
||||
"name": "Rapport CRM", "description": "d", "prompt_template": "fais le rapport",
|
||||
"allowed_tools": ["read_document", "create_document"],
|
||||
}, headers=headers).json()
|
||||
|
||||
export = client.get(f"/api/v2/skills/{created['id']}/export", headers=headers)
|
||||
assert export.status_code == 200
|
||||
doc = export.json()
|
||||
assert doc["format"] == skill_gallery.EXPORT_FORMAT
|
||||
assert doc["version"] == skill_gallery.EXPORT_VERSION
|
||||
assert doc["skill"]["name"] == "Rapport CRM"
|
||||
assert doc["skill"]["allowed_tools"] == ["read_document", "create_document"]
|
||||
# Portable = no instance internals leak
|
||||
assert "id" not in doc["skill"] and "workspace_id" not in doc["skill"]
|
||||
assert "created_by" not in doc["skill"]
|
||||
|
||||
# Same instance, different name → import as-is would clash → 409 first
|
||||
clash = client.post("/api/v2/skills/import", json=doc, headers=headers)
|
||||
assert clash.status_code == 409
|
||||
|
||||
# Renamed import succeeds, overwrite updates the existing one
|
||||
doc["skill"]["name"] = "Rapport CRM (copie)"
|
||||
imp = client.post("/api/v2/skills/import", json=doc, headers=headers)
|
||||
assert imp.status_code == 201, imp.text
|
||||
assert imp.json()["skill"]["prompt_template"] == "fais le rapport"
|
||||
|
||||
doc["skill"]["prompt_template"] = "version 2"
|
||||
upd = client.post("/api/v2/skills/import", json={**doc, "overwrite": True},
|
||||
headers=headers)
|
||||
assert upd.status_code in (200, 201), upd.text
|
||||
reimported = client.get(f"/api/v2/skills/{imp.json()['id']}", headers=headers).json()
|
||||
assert reimported["prompt_template"] == "version 2"
|
||||
|
||||
|
||||
def test_v2_skill_import_validation(client):
|
||||
headers = _token(client, "validator")
|
||||
|
||||
assert client.post("/api/v2/skills/import", json={"nope": True},
|
||||
headers=headers).status_code == 400
|
||||
assert client.post("/api/v2/skills/import",
|
||||
json={"format": "not-flowdeck", "skill": {"name": "x",
|
||||
"prompt_template": "y"}},
|
||||
headers=headers).status_code == 400
|
||||
assert client.post("/api/v2/skills/import",
|
||||
json={"format": skill_gallery.EXPORT_FORMAT, "version": 99,
|
||||
"skill": {"name": "x", "prompt_template": "y"}},
|
||||
headers=headers).status_code == 400
|
||||
assert client.post("/api/v2/skills/import",
|
||||
json={"name": "sans outils", "prompt_template": "",
|
||||
"allowed_tools": "not-a-list"},
|
||||
headers=headers).status_code == 400
|
||||
|
||||
|
||||
def test_v2_gallery_install(client):
|
||||
headers = _token(client, "galery")
|
||||
|
||||
gallery = client.get("/api/v2/skills/gallery", headers=headers)
|
||||
assert gallery.status_code == 200
|
||||
presets = gallery.json()["gallery"]
|
||||
assert len(presets) >= 6
|
||||
slugs = [p["slug"] for p in presets]
|
||||
assert "rapport-hebdo" in slugs and "base-crm" in slugs
|
||||
for p in presets:
|
||||
assert p["prompt_template"], f"preset {p['slug']} has no prompt"
|
||||
for tool in p["allowed_tools"]:
|
||||
assert tool in _known_tools(), f"preset {p['slug']} uses unknown tool {tool}"
|
||||
|
||||
r = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={},
|
||||
headers=headers)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["status"] == "installed"
|
||||
skill_id = r.json()["id"]
|
||||
|
||||
# Installed preset shows up in the list, and can be applied
|
||||
names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]]
|
||||
assert "Rapport hebdo" in names
|
||||
applied = client.post(f"/api/v2/skills/{skill_id}/apply", json={}, headers=headers)
|
||||
assert applied.status_code == 201
|
||||
conv = client.get(f"/api/v2/agents/conversations/{applied.json()['conversation_id']}",
|
||||
headers=headers)
|
||||
assert conv.status_code == 200
|
||||
|
||||
# Re-install updates instead of duplicating
|
||||
again = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={},
|
||||
headers=headers)
|
||||
assert again.status_code in (200, 201)
|
||||
names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]]
|
||||
assert names.count("Rapport hebdo") == 1
|
||||
|
||||
assert client.post("/api/v2/skills/gallery/does-not-exist/install", json={},
|
||||
headers=headers).status_code == 404
|
||||
|
||||
|
||||
def _known_tools() -> set[str]:
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
return set(ToolRegistry().tools.keys())
|
||||
|
||||
|
||||
# ── Internal (session) marketplace routes ───────────────────────────────────
|
||||
|
||||
def test_internal_gallery_and_skill_lifecycle(client):
|
||||
gallery = client.get("/api/agent/skills/gallery")
|
||||
assert gallery.status_code == 200
|
||||
assert gallery.json()["total"] >= 6
|
||||
|
||||
installed = client.post("/api/agent/skills/gallery/base-crm/install", json={})
|
||||
assert installed.status_code == 200, installed.text
|
||||
skill_id = installed.json()["id"]
|
||||
|
||||
export = client.get(f"/api/agent/skills/{skill_id}/export")
|
||||
assert export.status_code == 200
|
||||
assert export.json()["format"] == skill_gallery.EXPORT_FORMAT
|
||||
|
||||
doc = export.json()
|
||||
doc["skill"]["name"] = "Base CRM (import)"
|
||||
imp = client.post("/api/agent/skills/import", json=doc)
|
||||
assert imp.status_code == 200, imp.text
|
||||
assert imp.json()["name"] == "Base CRM (import)"
|
||||
|
||||
assert client.post("/api/agent/skills/gallery/nope/install",
|
||||
json={}).status_code == 404
|
||||
assert client.delete(f"/api/agent/skills/{skill_id}").status_code == 200
|
||||
assert client.get(f"/api/agent/skills/{skill_id}/export").status_code == 404
|
||||
|
||||
|
||||
# ── Agent run lifecycle webhooks ────────────────────────────────────────────
|
||||
|
||||
def test_agent_run_lifecycle_webhooks(client, monkeypatch):
|
||||
"""started → finished on success, started → failed on LLM error."""
|
||||
from app.db import get_conn
|
||||
from app.services import webhook_outbound
|
||||
|
||||
fired: list[tuple[str, dict]] = []
|
||||
|
||||
async def record(event, payload):
|
||||
fired.append((event, dict(payload)))
|
||||
|
||||
monkeypatch.setattr(webhook_outbound, "fire_event", record)
|
||||
|
||||
headers = _token(client, "hooks")
|
||||
conv_id = client.post("/api/v2/agents/conversations", json={"title": "Hooks"},
|
||||
headers=headers).json()["id"]
|
||||
|
||||
r = client.post(f"/api/v2/agents/conversations/{conv_id}/run",
|
||||
json={"message": "Crée une page de notes"}, headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
events = [e for e, _ in fired]
|
||||
assert "agent.run.started" in events
|
||||
assert "agent.run.finished" in events
|
||||
assert events.index("agent.run.started") < events.index("agent.run.finished")
|
||||
for event in events:
|
||||
assert event in EVENTS, f"{event} must be in the webhook catalogue"
|
||||
|
||||
# Failure path: the LLM blows up → started + failed, never finished.
|
||||
fired.clear()
|
||||
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise RuntimeError("llm down")
|
||||
|
||||
from app.services.llm_client import LLMClient
|
||||
monkeypatch.setattr(LLMClient, "complete", boom)
|
||||
|
||||
conv2 = client.post("/api/v2/agents/conversations", json={"title": "KO"},
|
||||
headers=headers).json()["id"]
|
||||
r = client.post(f"/api/v2/agents/conversations/{conv2}/run",
|
||||
json={"message": "ça va planter"}, headers=headers)
|
||||
assert r.status_code == 500
|
||||
assert r.json()["status"] == "failed"
|
||||
events = [e for e, _ in fired]
|
||||
assert "agent.run.started" in events
|
||||
assert "agent.run.failed" in events
|
||||
assert "agent.run.finished" not in events
|
||||
failed_payload = next(p for e, p in fired if e == "agent.run.failed")
|
||||
assert failed_payload["conversation_id"] == conv2
|
||||
assert "llm down" in failed_payload["error"]
|
||||
|
||||
# Conversation status must be released (finally block) for both paths.
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status FROM agent_conversations WHERE id IN (?, ?)", (conv_id, conv2)
|
||||
).fetchall()
|
||||
assert {r_["status"] for r_ in rows} == {"idle"}
|
||||
Reference in New Issue
Block a user