feat(agent): v4.10.0 FlowDeck Agent - agent IA ReAct (SSE, 18 outils + rollback, permissions, skills, triggers, multi-LLM)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-05 09:58:04 -04:00
parent 9eedfa67ca
commit e752e46583
17 changed files with 2638 additions and 36 deletions
+329
View File
@@ -0,0 +1,329 @@
"""FlowDeck — Agent tests (v4.10.0).
Covers the DB schema, LLM client (offline mock), permission manager, tool
registry (writes + undo snapshots + rollback), the ReAct engine (SSE events)
and the /api/agent/* router (agents, conversations, run, audit, skills).
"""
import asyncio
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client():
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.main import app
from app.db import init_db, get_conn
from app.password_utils import hash_password
from app.config import settings
# Ensure settings singleton uses OUR temp DB (not one set by another test file).
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()
yield TestClient(app)
try:
os.unlink(db_path)
except FileNotFoundError:
pass
def _admin_id() -> int:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone()
return row["id"] if row else 1
def _collect(events):
return [e for e in events]
async def _run_engine(engine, *args, **kwargs):
return [e async for e in engine.run(*args, **kwargs)]
# ── LLM client (offline mock) ──
def test_llm_client_offline_mock(client):
from app.services.llm_client import LLMClient
llm = LLMClient(provider="offline")
resp = asyncio.run(llm.complete(
[{"role": "user", "content": "create collection Projets"}], tools=[],
))
assert resp.tool_calls, "offline mock should detect a create-collection intent"
assert resp.tool_calls[0]["name"] == "create_collection"
assert resp.tool_calls[0]["arguments"]["name"] == "Projets"
def test_llm_client_providers_listed(client):
from app.services.llm_client import PROVIDERS
for name in ("openai", "anthropic", "google", "deepseek", "qwencloud",
"nvidia", "openrouter", "ollama", "offline"):
assert name in PROVIDERS
def test_llm_client_offline_no_credentials(client):
from app.services.llm_client import LLMClient
llm = LLMClient(provider="openai", api_key="")
resp = asyncio.run(llm.complete(
[{"role": "user", "content": "hello"}], tools=[],
))
# No key → falls back to mock (a final text answer, no crash)
assert resp.text != "" or resp.tool_calls
# ── DB schema ──
def test_agent_tables_exist(client):
tables = {"agents", "agent_conversations", "agent_messages",
"agent_actions", "agent_skills", "agent_triggers"}
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
names = {r["name"] for r in rows}
assert tables.issubset(names)
# ── Tools ──
def test_tool_create_collection_and_search(client):
from app.services.tool_registry import ToolRegistry
reg = ToolRegistry()
res = asyncio.run(reg.execute("create_collection", {"name": "Projets"}))
assert res.status == "success"
cid = res.data["collection_id"]
assert res.undo == {"action": "delete", "table": "collections", "id": cid}
search = asyncio.run(reg.execute("search_workspace", {"query": "Projets"}))
assert search.status == "success"
assert any(c["id"] == cid for c in search.data["collections"])
def test_tool_create_page_with_properties(client):
from app.services.tool_registry import ToolRegistry
reg = ToolRegistry()
coll = asyncio.run(reg.execute("create_collection", {"name": "Tasks"}))
cid = coll.data["collection_id"]
asyncio.run(reg.execute("add_property", {"collection_id": cid, "name": "Status", "prop_type": "select", "options": ["Todo", "Done"]}))
page = asyncio.run(reg.execute("create_page", {"collection_id": cid, "title": "Task 1", "properties": {"Status": "Todo"}}))
assert page.status == "success"
assert page.target_type == "page"
assert page.data["title"] == "Task 1"
def test_tool_unknown_returns_error(client):
from app.services.tool_registry import ToolRegistry
res = asyncio.run(ToolRegistry().execute("nope", {}))
assert res.status == "error"
# ── Permissions ──
def test_permission_manager_roles(client):
from app.services.permission_manager import PermissionManager
from app.db import get_conn
with get_conn() as conn:
owner = conn.execute("SELECT id FROM users WHERE login='admin'").fetchone()
conn.execute("INSERT INTO users (login, is_admin) VALUES ('viewer_user', 0)")
viewer = conn.execute("SELECT id FROM users WHERE login='viewer_user'").fetchone()
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('ws', ?)", (owner["id"],))
ws = conn.execute("SELECT id FROM workspaces WHERE name='ws'").fetchone()
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], viewer["id"], "viewer"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws["id"], owner["id"], "owner"))
conn.commit()
pm_owner = PermissionManager(owner["id"])
pm_viewer = PermissionManager(viewer["id"])
assert pm_owner.can_write(ws["id"]) is True
assert pm_viewer.can_write(ws["id"]) is False
from fastapi import HTTPException
# viewer cannot write
with pytest.raises(HTTPException):
pm_viewer.assert_can("create_page", {}, ws["id"])
# owner can write
pm_owner.assert_can("create_page", {}, ws["id"])
# destructive requires confirm mode
with pytest.raises(HTTPException):
pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="auto")
pm_owner.assert_can("delete_page", {}, ws["id"], approval_mode="confirm")
# ── Engine (ReAct + audit + rollback) ──
def test_engine_run_creates_collection(client):
from app.services.agent_engine import AgentEngine
from app.db import get_conn
engine = AgentEngine(_admin_id(), workspace_id=None)
events = asyncio.run(_run_engine(engine, _make_conversation(client), "crée une collection Stats"))
types = [e["type"] for e in events]
assert "reasoning" in types
assert "action" in types
assert "final" in types
actions = [e for e in events if e["type"] == "action"]
assert any(a["tool"] == "create_collection" and a["status"] == "success" for a in actions)
# Collection actually persisted
with get_conn() as conn:
row = conn.execute("SELECT id FROM collections WHERE name='Stats'").fetchone()
assert row is not None
def test_engine_audit_and_undo(client):
from app.services.agent_engine import AgentEngine, undo_action
from app.db import get_conn
conv = _make_conversation(client)
engine = AgentEngine(_admin_id())
asyncio.run(_run_engine(engine, conv, "créer une collection CRM"))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone()
assert coll is not None
action = conn.execute(
"SELECT id FROM agent_actions WHERE tool_name='create_collection' AND target_id=?",
(str(coll["id"]),),
).fetchone()
assert action is not None
# undo removes the collection and marks the action reverted
undo_action(action["id"])
from app.db import get_conn as gc
with gc() as conn:
assert conn.execute("SELECT id FROM collections WHERE name='CRM'").fetchone() is None
a = conn.execute("SELECT status FROM agent_actions WHERE id=?", (action["id"],)).fetchone()
assert a["status"] == "reverted"
def _make_conversation(client) -> int:
resp = client.post("/api/agent/conversations", json={"title": "test"})
return resp.json()["id"]
# ── Router: agents, conversations, run, skills ──
def test_router_list_agents(client):
resp = client.get("/api/agent")
assert resp.status_code == 200
assert resp.json()["agents"]
assert any(a["agent_type"] == "personal" for a in resp.json()["agents"])
def test_router_create_custom_agent(client):
resp = client.post("/api/agent", json={"name": "Reporting", "agent_type": "custom",
"system_instructions": "Produce weekly report"})
assert resp.status_code == 200
assert resp.json()["id"]
# list now includes it
agents = client.get("/api/agent").json()["agents"]
assert any(a["name"] == "Reporting" for a in agents)
def test_router_conversation_crud(client):
c = client.post("/api/agent/conversations", json={"title": "Hello"}).json()
cid = c["id"]
detail = client.get(f"/api/agent/conversations/{cid}")
assert detail.status_code == 200
assert detail.json()["conversation"]["id"] == cid
assert client.delete(f"/api/agent/conversations/{cid}").status_code == 200
assert client.get(f"/api/agent/conversations/{cid}").status_code == 404
def test_router_run_sse_stream(client):
cid = _make_conversation(client)
resp = client.post(f"/api/agent/conversations/{cid}/run",
json={"message": "create collection Roadmap"})
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
body = resp.text
assert "data:" in body
events = [_parse_sse(line) for line in body.strip().split("\n\n") if line.strip()]
types = [e.get("type") for e in events if e]
assert "final" in types
def test_router_run_malformed(client):
cid = _make_conversation(client)
resp = client.post(f"/api/agent/conversations/{cid}/run", json={})
assert resp.status_code == 400
def test_router_actions_and_undo(client):
cid = _make_conversation(client)
client.post(f"/api/agent/conversations/{cid}/run", json={"message": "créer une collection Docs"})
actions = client.get(f"/api/agent/conversations/{cid}/actions").json()["actions"]
assert actions, "run should have logged actions"
target = actions[0]
assert target["tool_name"] == "create_collection"
# undo via endpoint
r = client.post(f"/api/agent/actions/{target['id']}/undo")
assert r.status_code == 200
assert r.json()["status"] == "reverted"
def test_router_skills(client):
s = client.post("/api/agent/skills", json={"name": "Préparation de sprint",
"prompt_template": "Analyse les issues et crée un sprint",
"allowed_tools": ["read_gitea_issues", "create_collection"]})
assert s.status_code == 200
sid = s.json()["id"]
skills = client.get("/api/agent/skills").json()["skills"]
assert any(k["name"] == "Préparation de sprint" for k in skills)
# apply → creates a conversation ready to run
apply = client.post(f"/api/agent/skills/{sid}/apply")
assert apply.status_code == 200
assert apply.json()["conversation_id"]
def test_router_tools_and_providers(client):
tools = client.get("/api/agent/tools").json()["tools"]
assert len(tools) >= 10
names = {t["name"] for t in tools}
for expected in ("search_workspace", "create_collection", "create_page",
"update_page", "add_property", "create_view", "read_page"):
assert expected in names
prov = client.get("/api/agent/providers").json()
assert prov["provider"] == "offline"
assert prov["available"] is False
def _parse_sse(line: str) -> dict:
for part in line.split("\n"):
if part.startswith("data:"):
try:
return json.loads(part[len("data:"):].strip())
except json.JSONDecodeError:
return {}
return {}