Deux causes indépendantes faisaient échouer le job test de la CI (et seulement en CI : ni .env ni les mêmes ordres de chargement en local) : - test_agent_web_tools.mock_http patche http_client.shared_client pendant sa fenêtre d'exécution. Le premier import d'importers/url_fetch dans cette fenêtre fige la factory moquée dans le namespace du module → tous les appels fetch_url suivants du processus passaient par le handler mocké de l'autre test (« assert '…/post' == '…/page' » dans test_v56_import). url_fetch résout désormais le client à l'appel, et le mock_http restaure la vraie factory (référence figée au chargement du module) y compris sur url_fetch ; - test_agent.py posait RATE_LIMIT_ENABLED=false en env var, sans effet sur le singleton Settings déjà instancié : sous pytest -n auto, le worker dépassait le quota de 60 req/min et 8 tests recevaient des 429 (KeyError 'id' au passage). Le fixture désactive maintenant le limiter sur le singleton, comme les autres fichiers de tests.
1203 lines
49 KiB
Python
1203 lines
49 KiB
Python
"""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 conftest import login_test_client
|
|
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.config import settings
|
|
from app.db import get_conn, init_db
|
|
from app.main import app
|
|
from app.password_utils import hash_password
|
|
|
|
# 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
|
|
# Le rate limiter est DÉJÀ instancié (singleton importé avant ce fixture) :
|
|
# `os.environ["RATE_LIMIT_ENABLED"]="false"]` ci-dessus ne le touche pas.
|
|
# Sans cette mutation, les tests suivants d'un même worker dépassent le
|
|
# quota (60 req/min) et reçoivent des 429 — visible sous `pytest -n auto`
|
|
# en CI, invisible en local (.env de dev désactivé le limiter).
|
|
settings.rate_limit_enabled = False
|
|
|
|
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 login_test_client(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_mistral_defaults_are_tier_safe(client):
|
|
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
|
|
Le modèle par défaut et le premier de la liste du test de connexion doivent
|
|
être des modèles servis par tous les plans."""
|
|
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
|
|
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
|
|
assert PROVIDERS["mistral"][1] not in tier_blocked
|
|
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
|
|
# le fallback du modèle retiré (PROVIDERS default) est présent dans la liste
|
|
assert PROVIDERS["mistral"][1] in PROVIDER_MODELS["mistral"]
|
|
# et la liste des modèles Mistral est validée chat à chaque fetch (403 exclus)
|
|
from app.services.llm_config import _CHAT_VALIDATED_PROVIDERS
|
|
assert "mistral" in _CHAT_VALIDATED_PROVIDERS
|
|
|
|
|
|
def test_llm_client_openai_compatible_bases(client):
|
|
"""Providers that need an OpenAI-compatible surface must point at it.
|
|
|
|
Anthropic/Google/Cohere native APIs are not chat-completions endpoints:
|
|
the client always speaks `/chat/completions` with a Bearer token, so the
|
|
base URLs must target the compatibility surfaces (validated live).
|
|
"""
|
|
from app.services.llm_client import PROVIDERS
|
|
assert PROVIDERS["cohere"][0] == "https://api.cohere.ai/compatibility/v1"
|
|
assert PROVIDERS["google"][0] == (
|
|
"https://generativelanguage.googleapis.com/v1beta/openai")
|
|
# Perplexity serves /chat/completions at the host root (no /v1 prefix).
|
|
assert PROVIDERS["perplexity"][0] == "https://api.perplexity.ai"
|
|
# Chutes' OpenAI-compatible inference host.
|
|
assert PROVIDERS["chutes"][0] == "https://llm.chutes.ai/v1"
|
|
assert PROVIDERS["sensenova"][0].endswith("/compatible-mode/v1")
|
|
|
|
|
|
def test_user_llm_key_api_base_normalization(client):
|
|
"""A stored api_base equal to the provider default is not persisted as an
|
|
override, so a corrected provider URL always takes effect."""
|
|
from app.services.llm_config import get_user_llm_key, upsert_user_llm_key
|
|
|
|
uid = _admin_id()
|
|
# default base → stored as empty (no override)
|
|
upsert_user_llm_key(uid, "mistral", api_key="sk-test",
|
|
api_base="https://api.mistral.ai/v1")
|
|
assert get_user_llm_key(uid, "mistral")["api_base"] == ""
|
|
# custom base → kept
|
|
upsert_user_llm_key(uid, "mistral", api_base="https://proxy.example.com/v1")
|
|
assert get_user_llm_key(uid, "mistral")["api_base"] == "https://proxy.example.com/v1"
|
|
# api_base="" resets the override, api_base=None keeps it
|
|
upsert_user_llm_key(uid, "mistral", api_base="")
|
|
assert get_user_llm_key(uid, "mistral")["api_base"] == ""
|
|
upsert_user_llm_key(uid, "mistral", api_base="https://proxy.example.com/v1")
|
|
upsert_user_llm_key(uid, "mistral", default_model="mistral-large-latest")
|
|
assert get_user_llm_key(uid, "mistral")["api_base"] == "https://proxy.example.com/v1"
|
|
|
|
|
|
def test_migration_clears_stale_llm_api_bases(client):
|
|
"""Migration 14 clears legacy/incorrect api_base values so the corrected
|
|
provider default applies (e.g. Mistral `/v2` → `/v1`)."""
|
|
from app.db import get_conn
|
|
from app.migrations import _migration_fix_llm_api_bases
|
|
from app.services.llm_config import get_user_llm_key, upsert_user_llm_key
|
|
|
|
uid = _admin_id()
|
|
upsert_user_llm_key(uid, "mistral", api_key="sk-test")
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE user_llm_keys SET api_base='https://api.mistral.ai/v2' "
|
|
"WHERE user_id=? AND provider='mistral'",
|
|
(uid,),
|
|
)
|
|
conn.commit()
|
|
assert get_user_llm_key(uid, "mistral")["api_base"] == "https://api.mistral.ai/v2"
|
|
with get_conn() as conn:
|
|
_migration_fix_llm_api_bases(conn)
|
|
conn.commit()
|
|
assert get_user_llm_key(uid, "mistral")["api_base"] == ""
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_llm_client_offline_mock_creates_document(client):
|
|
"""A document creation request must resolve to the create_document tool."""
|
|
from app.services.llm_client import LLMClient
|
|
llm = LLMClient(provider="offline")
|
|
resp = asyncio.run(llm.complete(
|
|
[{"role": "user", "content": 'Faire la création d\'un document "Projet" '
|
|
'dans le workspace "TEST WS 1"'}],
|
|
tools=[],
|
|
))
|
|
assert resp.tool_calls
|
|
assert resp.tool_calls[0]["name"] == "create_document"
|
|
args = resp.tool_calls[0]["arguments"]
|
|
assert args["title"] == "Projet"
|
|
assert args["workspace_name"] == "TEST WS 1"
|
|
|
|
|
|
# ── DB schema ──
|
|
|
|
def test_agent_tables_exist(client):
|
|
tables = {"agents", "agent_conversations", "agent_messages",
|
|
"agent_actions", "agent_skills", "agent_triggers",
|
|
"agent_feedback"}
|
|
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"
|
|
|
|
|
|
def test_tool_workspace_document_crud_and_search(client):
|
|
from app.db import get_conn
|
|
from app.services.tool_registry import ToolRegistry
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('TEST WS 1', ?)",
|
|
(_admin_id(),))
|
|
conn.commit()
|
|
|
|
reg = ToolRegistry()
|
|
|
|
ws = asyncio.run(reg.execute("read_workspaces", {}))
|
|
assert ws.status == "success"
|
|
assert any(w["name"] == "TEST WS 1" for w in ws.data["workspaces"])
|
|
|
|
res = asyncio.run(reg.execute("create_document",
|
|
{"title": "Projet", "workspace_name": "TEST WS 1"}))
|
|
assert res.status == "success", res.message
|
|
pid = res.data["document_id"]
|
|
assert res.target_type == "document"
|
|
assert res.undo == {"action": "delete", "table": "pages", "id": pid}
|
|
|
|
rd = asyncio.run(reg.execute("read_document", {"page_id": pid}))
|
|
assert rd.status == "success"
|
|
assert rd.data["document"]["title"] == "Projet"
|
|
|
|
ws2 = asyncio.run(reg.execute("read_workspaces", {}))
|
|
row = next(w for w in ws2.data["workspaces"] if w["name"] == "TEST WS 1")
|
|
assert row["document_count"] == 1
|
|
|
|
search = asyncio.run(reg.execute("search_workspace", {"query": "Projet"}))
|
|
assert any(d["id"] == pid for d in search.data["documents"])
|
|
|
|
# write_blocks can also rename the document
|
|
upd = asyncio.run(reg.execute("write_blocks", {"page_id": pid, "title": "Projet v2"}))
|
|
assert upd.status == "success"
|
|
rd2 = asyncio.run(reg.execute("read_document", {"page_id": pid}))
|
|
assert rd2.data["document"]["title"] == "Projet v2"
|
|
|
|
# unknown workspace name → clear error, no document created
|
|
bad = asyncio.run(reg.execute("create_document",
|
|
{"title": "X", "workspace_name": "NOPE"}))
|
|
assert bad.status == "error"
|
|
assert "introuvable" in bad.message
|
|
|
|
|
|
def test_write_blocks_normalizes_llm_blocks(client):
|
|
"""LLM blocks are normalized: `text`→`content`, `heading`→`heading_1`,
|
|
missing ids are generated, unknown types fall back to paragraph."""
|
|
from app.services.tool_registry import ToolRegistry
|
|
|
|
reg = ToolRegistry()
|
|
doc = asyncio.run(reg.execute("create_document", {"title": "Script"}))
|
|
pid = doc.data["document_id"]
|
|
|
|
bad_blocks = [
|
|
{"type": "heading", "text": "test.ps1 — Script de test PowerShell"},
|
|
{"type": "paragraph", "text": "# Test script\nWrite-Host 'Hello, World!'"},
|
|
{"type": "code", "text": "Write-Host 'hi'", "language": "powershell"},
|
|
{"type": "made_up_type", "content": "inconnu"},
|
|
]
|
|
res = asyncio.run(reg.execute("write_blocks", {"page_id": pid, "blocks": bad_blocks}))
|
|
assert res.status == "success", res.message
|
|
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (pid,)).fetchone()
|
|
assert row["content_format"] == "blocks"
|
|
stored = json.loads(row["content"])
|
|
|
|
assert stored[0]["type"] == "heading_1", stored[0]
|
|
assert stored[0]["content"] == "test.ps1 — Script de test PowerShell"
|
|
assert stored[0]["id"], "id must be generated"
|
|
|
|
assert stored[1]["type"] == "paragraph"
|
|
assert stored[1]["content"] == "# Test script\nWrite-Host 'Hello, World!'"
|
|
|
|
assert stored[2]["type"] == "code"
|
|
assert stored[2]["content"] == "Write-Host 'hi'"
|
|
assert stored[2]["language"] == "powershell"
|
|
|
|
assert stored[3]["type"] == "paragraph" # unknown → paragraph
|
|
assert stored[3]["content"] == "inconnu"
|
|
|
|
|
|
def test_engine_titles_conversation_on_each_request(client):
|
|
"""The conversation title is refreshed after EVERY AI request (not only
|
|
while the title is a default placeholder)."""
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
engine = AgentEngine(_admin_id(), workspace_id=None)
|
|
conv = client.post("/api/agent/conversations",
|
|
json={"title": "Nouvelle conversation"}).json()["id"]
|
|
first = "crée un document « Rapport Q3 »"
|
|
asyncio.run(_run_engine(engine, conv, first))
|
|
detail = client.get(f"/api/agent/conversations/{conv}").json()
|
|
title1 = detail["conversation"]["title"]
|
|
assert "Rapport Q3" in title1
|
|
|
|
second = "crée une collection « Launches »"
|
|
asyncio.run(_run_engine(engine, conv, second))
|
|
detail = client.get(f"/api/agent/conversations/{conv}").json()
|
|
title2 = detail["conversation"]["title"]
|
|
assert "Launches" in title2, title2
|
|
assert title2 != title1, "title must be updated on the second request too"
|
|
|
|
|
|
def test_suggest_title_strips_composer_prefixes(client):
|
|
from app.services.agent_engine import AgentEngine
|
|
title = AgentEngine._suggest_title(
|
|
"Contexte « test.ps1 »\n\nÉcrire un script de test en powershell dans la page courante",
|
|
None,
|
|
)
|
|
assert title != "Contexte", title
|
|
assert "Contexte" not in title
|
|
assert title
|
|
|
|
|
|
def test_tool_delete_document_and_undo(client):
|
|
from app.db import get_conn
|
|
from app.services.agent_engine import undo_action
|
|
from app.services.tool_registry import ToolRegistry
|
|
|
|
reg = ToolRegistry()
|
|
res = asyncio.run(reg.execute("create_document", {"title": "À supprimer"}))
|
|
pid = res.data["document_id"]
|
|
|
|
dele = asyncio.run(reg.execute("delete_document", {"page_id": pid}))
|
|
assert dele.status == "success"
|
|
assert dele.undo == {"action": "softdelete", "table": "pages", "id": pid}
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
|
|
assert row is not None and row["deleted_at"] is not None
|
|
|
|
# store an audit row so undo_action can restore the document
|
|
conv_id = client.post("/api/agent/conversations", json={"title": "t"}).json()["id"]
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO agent_actions (conversation_id, tool_name, target_type,
|
|
target_id, payload_json, result_json, status, undo_snapshot_json, executed_by)
|
|
VALUES (?, 'delete_document', 'document', ?, '{}', '{}', 'success', ?, ?)""",
|
|
(conv_id, str(pid), json.dumps(dele.undo), _admin_id()),
|
|
)
|
|
conn.commit()
|
|
aid = cur.lastrowid
|
|
|
|
assert undo_action(aid) is True
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
|
|
assert row["deleted_at"] is None
|
|
|
|
|
|
# ── Permissions ──
|
|
|
|
def test_permission_manager_roles(client):
|
|
from app.db import get_conn
|
|
from app.services.permission_manager import PermissionManager
|
|
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.db import get_conn
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
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.db import get_conn
|
|
from app.services.agent_engine import AgentEngine, undo_action
|
|
|
|
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 test_engine_run_creates_document_in_workspace_and_titles_conversation(client):
|
|
from app.db import get_conn
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('TEST WS 1', ?)",
|
|
(_admin_id(),))
|
|
conn.commit()
|
|
|
|
engine = AgentEngine(_admin_id(), workspace_id=None)
|
|
conv = client.post("/api/agent/conversations",
|
|
json={"title": "Nouvelle conversation"}).json()["id"]
|
|
objective = 'Faire la création d\'un document « Projet » dans le workspace « TEST WS 1 »'
|
|
events = asyncio.run(_run_engine(engine, conv, objective))
|
|
|
|
actions = [e for e in events if e["type"] == "action"]
|
|
assert any(a["tool"] == "create_document" and a["status"] == "success" for a in actions), actions
|
|
|
|
with get_conn() as conn:
|
|
doc = conn.execute("SELECT * FROM pages WHERE title='Projet'").fetchone()
|
|
ws = conn.execute("SELECT id FROM workspaces WHERE name='TEST WS 1'").fetchone()
|
|
assert doc is not None
|
|
assert doc["workspace_id"] == ws["id"]
|
|
|
|
# the conversation is renamed with a descriptive title
|
|
detail = client.get(f"/api/agent/conversations/{conv}").json()
|
|
title = detail["conversation"]["title"]
|
|
assert title.lower() != "nouvelle conversation"
|
|
assert "Projet" in title
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, text="", tool_calls=None):
|
|
self.text = text
|
|
self.tool_calls = tool_calls or []
|
|
self.usage = {}
|
|
|
|
|
|
def test_engine_tool_protocol_messages(client):
|
|
"""Le message assistant qui annonce un outil porte ses `tool_calls`, et le
|
|
résultat d'outil est renvoyé avec le `tool_call_id` correspondant — sans quoi
|
|
le fournisseur refuse la passe suivante (et l'agent retombait sur le mock)."""
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
class FakeLLM:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
|
self.calls.append([dict(m) for m in messages])
|
|
if len(self.calls) == 1:
|
|
return _Resp("Plan de création.",
|
|
[{"id": "call_abc", "name": "create_collection",
|
|
"arguments": {"name": "ProtoCol"}}])
|
|
return _Resp("Collection ProtoCol créée.", [])
|
|
|
|
fake = FakeLLM()
|
|
engine = AgentEngine(_admin_id(), llm=fake)
|
|
conv = client.post("/api/agent/conversations",
|
|
json={"title": "protocol"}).json()["id"]
|
|
events = asyncio.run(_run_engine(engine, conv, "crée la collection ProtoCol"))
|
|
|
|
finals = [e for e in events if e["type"] == "final"]
|
|
assert any("ProtoCol" in e.get("content", "") for e in finals), events
|
|
|
|
# la 2e passe reçoit un bloc assistant `tool_calls` + un résultat d'outil lié
|
|
call2 = fake.calls[1]
|
|
asm = next((m for m in call2 if m["role"] == "assistant" and m.get("tool_calls")), None)
|
|
tmsg = next((m for m in call2 if m["role"] == "tool"), None)
|
|
assert asm is not None and tmsg is not None
|
|
assert asm["tool_calls"][0]["id"] == "call_abc"
|
|
assert tmsg["tool_call_id"] == asm["tool_calls"][0]["id"]
|
|
payload = json.loads(tmsg["content"])
|
|
assert payload["status"] == "ok"
|
|
|
|
|
|
def test_engine_multiple_skills_applied(client):
|
|
"""Plusieurs skills (skill_ids) sont injectés ensemble dans les instructions."""
|
|
from app.db import get_conn
|
|
from app.services.agent_engine import AgentEngine
|
|
|
|
with get_conn() as conn:
|
|
c1 = conn.execute(
|
|
"INSERT INTO agent_skills (workspace_id, name, description, prompt_template, allowed_tools_json, created_by) "
|
|
"VALUES (NULL, 'Skill A', '', 'Template A', '[]', ?)", (_admin_id(),))
|
|
c2 = conn.execute(
|
|
"INSERT INTO agent_skills (workspace_id, name, description, prompt_template, allowed_tools_json, created_by) "
|
|
"VALUES (NULL, 'Skill B', '', 'Template B', '[]', ?)", (_admin_id(),))
|
|
conn.commit()
|
|
id1, id2 = c1.lastrowid, c2.lastrowid
|
|
|
|
class FakeLLM:
|
|
def __init__(self):
|
|
self.system = ""
|
|
|
|
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
|
self.system = next((m["content"] for m in messages if m["role"] == "system"), "")
|
|
return _Resp("ok", [])
|
|
|
|
fake = FakeLLM()
|
|
engine = AgentEngine(_admin_id(), llm=fake)
|
|
conv = client.post("/api/agent/conversations", json={"title": "multi-skill"}).json()["id"]
|
|
asyncio.run(_run_engine(engine, conv, "bonjour", skill_ids=[id1, id2]))
|
|
|
|
assert "Skill A" in fake.system and "Template A" in fake.system
|
|
assert "Skill B" in fake.system and "Template B" in fake.system
|
|
|
|
|
|
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_mentions(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format) "
|
|
"VALUES ('', 'Compte rendu projet', '## Résumé\nDécision prise.', 'markdown')")
|
|
doc_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.execute("INSERT INTO collections (name, icon) VALUES ('Roadmap', '🗺️')")
|
|
coll_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO collection_pages (collection_id, title, property_values_json) "
|
|
"VALUES (?, 'Page Analyse', '{}')", (coll_id,))
|
|
page_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.commit()
|
|
|
|
r = client.get("/api/agent/mentions")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
sections = data["sections"]
|
|
assert isinstance(sections, list) and sections
|
|
tokens = {it["token"] for grp in sections for it in grp["items"]}
|
|
assert f"document:{doc_id}" in tokens
|
|
assert f"collection:{coll_id}" in tokens
|
|
assert f"page:{page_id}" in tokens
|
|
|
|
# search filter
|
|
r2 = client.get("/api/agent/mentions", params={"q": "Compte rendu"})
|
|
found = [it for grp in r2.json()["sections"] for it in grp["items"]]
|
|
assert found and all(it["type"] == "document" and it["label"] == "Compte rendu projet" for it in found)
|
|
|
|
# déduplication : deux fichiers avec le même titre n'apparaissent qu'une fois
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format) "
|
|
"VALUES ('', 'Compte rendu projet', 'Bis', 'markdown')")
|
|
conn.commit()
|
|
r3 = client.get("/api/agent/mentions")
|
|
dup_docs = [it for grp in r3.json()["sections"] for it in grp["items"]
|
|
if it["type"] == "document" and it["label"] == "Compte rendu projet"]
|
|
assert len(dup_docs) == 1, dup_docs
|
|
|
|
# scoping : les fichiers de l'espace courant ouvert sont proposés en section dédiée
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('Espace Scope', ?)", (_admin_id(),))
|
|
ws_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO pages (workspace, workspace_id, title, content, content_format) "
|
|
"VALUES ('Espace Scope', ?, 'Fichier du ws', '', 'markdown')", (ws_id,))
|
|
conn.commit()
|
|
r4 = client.get("/api/agent/mentions", params={"workspace_id": ws_id})
|
|
secs = r4.json()["sections"]
|
|
assert secs[0]["key"] == "files" and secs[0]["label"] == "Espace Scope"
|
|
file_tokens = {it["token"] for it in secs[0]["items"]}
|
|
assert any(t.startswith("document:") and (r4.json()["workspace_id"] == ws_id) for t in file_tokens)
|
|
|
|
|
|
def test_router_feedback(client):
|
|
cid = _make_conversation(client)
|
|
ok = client.post("/api/agent/feedback", json={
|
|
"rating": "up", "conversation_id": cid, "content": "Merci, très utile",
|
|
})
|
|
assert ok.status_code == 200
|
|
assert ok.json()["status"] == "recorded"
|
|
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT rating, snippet FROM agent_feedback").fetchall()
|
|
assert len(rows) == 1
|
|
assert rows[0]["rating"] == "up"
|
|
assert rows[0]["snippet"] == "Merci, très utile"
|
|
|
|
bad = client.post("/api/agent/feedback", json={"rating": "meh"})
|
|
assert bad.status_code == 400
|
|
|
|
|
|
def test_context_builder_document_mention(client):
|
|
import json as _json
|
|
|
|
from app.db import get_conn
|
|
from app.services.context_builder import ContextBuilder
|
|
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format) "
|
|
"VALUES ('ws', 'Note brainstorming', 'Contenu ultra secret du doc.', 'markdown')")
|
|
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format) "
|
|
"VALUES ('ws', 'Doc blocs', ?, 'blocks')",
|
|
(_json.dumps([
|
|
{"id": "b1", "type": "heading", "content": "Titre du bloc"},
|
|
{"id": "b2", "type": "paragraph", "content": "Paragraphe du bloc.",
|
|
"children": [{"id": "b3", "type": "paragraph", "content": "Enfant aussi."}]},
|
|
]),))
|
|
bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.commit()
|
|
|
|
ctx = ContextBuilder(_admin_id()).build(mentions=[f"document:{mid}"])
|
|
assert "Note brainstorming" in ctx
|
|
assert "Contenu ultra secret du doc." in ctx
|
|
|
|
ctx2 = ContextBuilder(_admin_id()).build(mentions=[f"document:{bid}"])
|
|
assert "Titre du bloc" in ctx2
|
|
assert "Paragraphe du bloc." in ctx2
|
|
assert "Enfant aussi." in ctx2
|
|
|
|
|
|
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",
|
|
"read_workspaces", "read_document", "create_document"):
|
|
assert expected in names
|
|
|
|
prov = client.get("/api/agent/providers").json()
|
|
assert prov["provider"] == "offline"
|
|
assert prov["available"] is False
|
|
assert "providers" in prov
|
|
|
|
|
|
def test_router_providers_enriched(client):
|
|
prov = client.get("/api/agent/providers").json()
|
|
assert "providers" in prov
|
|
ids = [p["id"] for p in prov["providers"]]
|
|
for expected in ("openai", "anthropic", "google", "ollama", "offline"):
|
|
assert expected in ids
|
|
openai = next(p for p in prov["providers"] if p["id"] == "openai")
|
|
assert openai["default_model"] == "gpt-4o"
|
|
assert openai["models"]
|
|
assert openai["requires_key"] is True
|
|
# offline requires no key and exposes no models
|
|
offline = next(p for p in prov["providers"] if p["id"] == "offline")
|
|
assert offline["requires_key"] is False
|
|
|
|
|
|
def test_router_patch_providers_updates_config(client):
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
admin = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
|
sess = SessionManager.create_session(dict(admin))
|
|
|
|
client.cookies.set("flowdeck_session", sess)
|
|
r = client.patch("/api/agent/providers",
|
|
json={"provider": "openai", "model": "gpt-4o-mini",
|
|
"api_key": "sk-test", "api_base": ""})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["provider"] == "openai"
|
|
assert body["model"] == "gpt-4o-mini"
|
|
assert body["has_api_key"] is True
|
|
|
|
# persisted in llm_config (single row id=1)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM llm_config WHERE id=1").fetchone()
|
|
assert row is not None
|
|
assert row["provider"] == "openai"
|
|
assert row["model"] == "gpt-4o-mini"
|
|
assert row["api_key"] == "sk-test"
|
|
|
|
# GET /providers reflects the new config
|
|
prov = client.get("/api/agent/providers").json()
|
|
assert prov["provider"] == "openai"
|
|
assert prov["model"] == "gpt-4o-mini"
|
|
assert prov["has_api_key"] is True
|
|
# a real provider with a key is marked available (no network call made)
|
|
assert prov["available"] is True
|
|
|
|
|
|
def test_router_patch_providers_requires_admin(client):
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT INTO users (login, full_name, is_admin) VALUES ('user_basic', 'Basic', 0)")
|
|
user = conn.execute("SELECT * FROM users WHERE login='user_basic'").fetchone()
|
|
conn.commit()
|
|
sess = SessionManager.create_session(dict(user))
|
|
client.cookies.set("flowdeck_session", sess)
|
|
|
|
r = client.patch("/api/agent/providers", json={"provider": "openai"})
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_llm_client_default_model_provider_scoped(client):
|
|
# Le modèle global ne doit pas fuir vers un autre provider testé (ex. nvidia
|
|
# alors que deepseek est actif) sinon le provider reçoit un modèle inconnu
|
|
# (« model not found », HTTP 404).
|
|
from app.services.llm_client import LLMClient
|
|
from app.services.llm_config import set_llm_config
|
|
|
|
set_llm_config(provider="deepseek", model="deepseek-v4-flash",
|
|
api_key="sk-global", api_base="https://api.deepseek.com/v1")
|
|
|
|
llm = LLMClient(provider="nvidia", api_key="nvapi-xxx")
|
|
assert llm.provider == "nvidia"
|
|
assert llm.default_model == "nvidia/nemotron-3-super-120b-a12b"
|
|
assert llm.default_model != "deepseek-v4-flash"
|
|
|
|
# le provider global conserve bien son modèle
|
|
llm2 = LLMClient()
|
|
assert llm2.provider == "deepseek"
|
|
assert llm2.default_model == "deepseek-v4-flash"
|
|
|
|
|
|
def test_router_providers_test_offline(client):
|
|
r = client.post("/api/agent/providers/test", json={"provider": "offline"})
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
|
|
|
|
def test_router_providers_test_missing_key(client):
|
|
# openai without a key must report failure (no mock fallback for the ping)
|
|
r = client.post("/api/agent/providers/test",
|
|
json={"provider": "openai", "api_key": ""})
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is False
|
|
assert "clé" in r.json()["error"].lower()
|
|
|
|
|
|
def test_router_run_persists_provider_model(client):
|
|
cid = _make_conversation(client)
|
|
resp = client.post(f"/api/agent/conversations/{cid}/run",
|
|
json={"message": "crée une collection Bilan",
|
|
"provider": "openai", "model": "gpt-4o-mini"})
|
|
assert resp.status_code == 200
|
|
detail = client.get(f"/api/agent/conversations/{cid}").json()
|
|
assert detail["conversation"]["provider"] == "openai"
|
|
assert detail["conversation"]["model"] == "gpt-4o-mini"
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
# ── Per-user provider keys (v4.10.2) ──
|
|
|
|
|
|
def _session_for_login(login: str) -> str:
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
|
return SessionManager.create_session(dict(user))
|
|
|
|
|
|
def test_router_user_keys_crud(client):
|
|
# no session → falls back to the single admin user
|
|
r = client.put("/api/agent/keys/openai",
|
|
json={"api_key": "sk-secret-1", "api_base": "", "default_model": "gpt-4o-mini"})
|
|
assert r.status_code == 200
|
|
assert r.json()["has_key"] is True
|
|
|
|
keys = client.get("/api/agent/keys").json()["keys"]
|
|
entry = [k for k in keys if k["provider"] == "openai"][0]
|
|
assert entry["has_key"] is True
|
|
assert entry["default_model"] == "gpt-4o-mini"
|
|
assert "sk-secret-1" not in json.dumps(entry), "raw api_key must never be exposed"
|
|
|
|
# empty key keeps the stored one
|
|
r = client.put("/api/agent/keys/openai",
|
|
json={"api_key": "", "models": ["gpt-4o", "gpt-4o-custom"]})
|
|
assert r.status_code == 200
|
|
keys = client.get("/api/agent/keys").json()["keys"]
|
|
entry = [k for k in keys if k["provider"] == "openai"][0]
|
|
assert entry["has_key"] is True
|
|
assert "gpt-4o-custom" in entry["models"]
|
|
|
|
# providers endpoint merges saved models + has_key flag
|
|
prov = client.get("/api/agent/providers").json()
|
|
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
|
|
assert p["has_key"] is True
|
|
assert "gpt-4o-custom" in p["models"]
|
|
|
|
r = client.delete("/api/agent/keys/openai")
|
|
assert r.status_code == 200
|
|
keys = client.get("/api/agent/keys").json()["keys"]
|
|
assert all(k["provider"] != "openai" for k in keys)
|
|
|
|
|
|
def test_router_user_keys_scoped_per_user(client):
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT INTO users (login, full_name, is_admin) VALUES ('alice', 'Alice', 0)")
|
|
conn.commit()
|
|
|
|
# admin saves a key
|
|
client.put("/api/agent/keys/deepseek", json={"api_key": "sk-admin-deepseek"})
|
|
assert client.get("/api/agent/keys").json()["keys"]
|
|
|
|
# alice saves her own key and must NOT see the admin's
|
|
client.cookies.set("flowdeck_session", _session_for_login("alice"))
|
|
client.put("/api/agent/keys/deepseek", json={"api_key": "sk-alice-deepseek"})
|
|
keys = client.get("/api/agent/keys").json()["keys"]
|
|
entry = [k for k in keys if k["provider"] == "deepseek"][0]
|
|
assert entry["has_key"] is True
|
|
|
|
from app.db import get_conn as _gc
|
|
with _gc() as conn:
|
|
rows = conn.execute(
|
|
"SELECT user_id, api_key FROM user_llm_keys WHERE provider='deepseek'"
|
|
).fetchall()
|
|
by_user = {r["user_id"]: r["api_key"] for r in rows}
|
|
assert len(by_user) == 2
|
|
assert "sk-admin-deepseek" in by_user.values()
|
|
assert "sk-alice-deepseek" in by_user.values()
|
|
# masked API never leaks a raw key
|
|
assert "sk-alice-deepseek" not in json.dumps(keys)
|
|
|
|
|
|
def test_router_user_keys_invalid_provider(client):
|
|
assert client.put("/api/agent/keys/bogus", json={"api_key": "x"}).status_code == 400
|
|
assert client.post("/api/agent/keys/bogus/models").status_code == 400
|
|
assert client.delete("/api/agent/keys/bogus").status_code == 400
|
|
|
|
|
|
def test_router_fetch_models_uses_stored_key(client, monkeypatch):
|
|
from app.routers import agent as agent_router
|
|
|
|
calls = []
|
|
|
|
async def fake_fetch(provider, *, api_key="", api_base="", timeout=20):
|
|
calls.append({"provider": provider, "api_key": api_key, "api_base": api_base})
|
|
return ["stub-a", "stub-b"]
|
|
|
|
monkeypatch.setattr(agent_router, "fetch_provider_models", fake_fetch)
|
|
|
|
# no stored key yet → empty key is sent
|
|
r = client.post("/api/agent/keys/openai/models", json={})
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
assert r.json()["models"] == ["stub-a", "stub-b"]
|
|
assert calls[-1]["api_key"] == ""
|
|
|
|
# stored key is used as fallback when the body carries none
|
|
client.put("/api/agent/keys/openai", json={"api_key": "sk-stored"})
|
|
r = client.post("/api/agent/keys/openai/models", json={"api_base": "https://proxy.home/v1"})
|
|
assert r.status_code == 200
|
|
assert calls[-1]["api_key"] == "sk-stored"
|
|
assert calls[-1]["api_base"] == "https://proxy.home/v1"
|
|
|
|
# body-provided key wins over the stored one
|
|
r = client.post("/api/agent/keys/openai/models", json={"api_key": "sk-typed"})
|
|
assert calls[-1]["api_key"] == "sk-typed"
|
|
|
|
|
|
def test_router_fetch_models_error_surfaced(client, monkeypatch):
|
|
from app.routers import agent as agent_router
|
|
|
|
async def fake_fetch(provider, *, api_key="", api_base="", timeout=20):
|
|
raise ConnectionError("injoignable")
|
|
|
|
monkeypatch.setattr(agent_router, "fetch_provider_models", fake_fetch)
|
|
r = client.post("/api/agent/keys/openai/models", json={"api_key": "sk-x"})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert "injoignable" in body["error"]
|
|
|
|
|
|
def test_router_patch_conversation(client):
|
|
cid = _make_conversation(client)
|
|
r = client.patch(f"/api/agent/conversations/{cid}",
|
|
json={"provider": "openai", "model": "gpt-4o-mini", "title": "Sprint préparé"})
|
|
assert r.status_code == 200
|
|
detail = client.get(f"/api/agent/conversations/{cid}").json()
|
|
conv = detail["conversation"]
|
|
assert conv["provider"] == "openai"
|
|
assert conv["model"] == "gpt-4o-mini"
|
|
assert conv["title"] == "Sprint préparé"
|
|
|
|
|
|
def test_router_run_uses_user_key(client, monkeypatch):
|
|
from app.routers import agent as agent_router
|
|
|
|
created = {}
|
|
|
|
class FakeLLM(agent_router.LLMClient):
|
|
def __init__(self, *args, **kwargs):
|
|
created.update(kwargs)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
|
return await self._mock_complete(messages, model, tools)
|
|
|
|
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
|
|
|
|
# user key saved → run must create the LLM client with that key
|
|
client.put("/api/agent/keys/openai", json={"api_key": "sk-user-secret", "api_base": "https://proxy.home/v1"})
|
|
cid = _make_conversation(client)
|
|
resp = client.post(f"/api/agent/conversations/{cid}/run",
|
|
json={"message": "crée une collection Bilan",
|
|
"provider": "openai", "model": "gpt-4o-mini"})
|
|
assert resp.status_code == 200
|
|
assert created.get("api_key") == "sk-user-secret"
|
|
assert created.get("api_base") == "https://proxy.home/v1"
|
|
|
|
# without a saved key the user key is not injected (global config/environment wins)
|
|
assert client.delete("/api/agent/keys/openai").status_code == 200
|
|
created.clear()
|
|
cid2 = _make_conversation(client)
|
|
resp = client.post(f"/api/agent/conversations/{cid2}/run",
|
|
json={"message": "crée une collection Bilan",
|
|
"provider": "openai", "model": "gpt-4o-mini"})
|
|
assert resp.status_code == 200
|
|
assert created.get("api_key", "") == ""
|
|
return {}
|
|
|
|
|
|
def test_router_user_key_test_verifies(client, monkeypatch):
|
|
"""A successful user-key test flags the provider as functional (verified)."""
|
|
from app.routers import agent as agent_router
|
|
from app.services.llm_client import LLMResponse
|
|
|
|
seen = {}
|
|
|
|
class FakeLLM(agent_router.LLMClient):
|
|
def __init__(self, *args, **kwargs):
|
|
seen.update(kwargs)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
async def ping(self, *, model=None):
|
|
return LLMResponse(text="PONG", model=model or "gpt-4o")
|
|
|
|
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
|
|
|
|
client.put("/api/agent/keys/openai", json={"api_key": "sk-user"})
|
|
r = client.post("/api/agent/keys/openai/test", json={"model": "gpt-4o-mini"})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert body["verified"] is True
|
|
assert seen.get("api_key") == "sk-user"
|
|
|
|
# the stored key row now carries verified=True
|
|
entry = [k for k in client.get("/api/agent/keys").json()["keys"]
|
|
if k["provider"] == "openai"][0]
|
|
assert entry["verified"] is True
|
|
|
|
# /providers exposes openai as configured + verified + functional
|
|
prov = client.get("/api/agent/providers").json()
|
|
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
|
|
assert p["has_key"] is True
|
|
assert p["configured"] is True
|
|
assert p["verified"] is True
|
|
assert p["functional"] is True
|
|
|
|
|
|
def test_router_user_key_test_failure(client, monkeypatch):
|
|
"""A failing test keeps the key but marks the provider non-functional."""
|
|
from app.routers import agent as agent_router
|
|
|
|
class FakeLLM(agent_router.LLMClient):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
async def ping(self, *, model=None):
|
|
raise PermissionError("Clé API manquante pour le provider « openai »")
|
|
|
|
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
|
|
|
|
client.put("/api/agent/keys/openai", json={"api_key": "sk-user"})
|
|
r = client.post("/api/agent/keys/openai/test", json={})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert body["verified"] is False
|
|
|
|
keys = client.get("/api/agent/keys").json()["keys"]
|
|
entry = [k for k in keys if k["provider"] == "openai"][0]
|
|
assert entry["verified"] is False
|
|
assert entry["last_error"]
|
|
|
|
prov = client.get("/api/agent/providers").json()
|
|
p = [x for x in prov["providers"] if x["id"] == "openai"][0]
|
|
assert p["functional"] is False
|
|
|
|
|
|
def test_generate_endpoint_returns_text(client, monkeypatch):
|
|
"""/api/agent/generate performs headless text generation (no tools)."""
|
|
from app.routers import agent as agent_router
|
|
from app.services.llm_client import LLMResponse
|
|
|
|
class FakeLLM(agent_router.LLMClient):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
async def complete(self, messages, *, model=None, tools=None, stream=False):
|
|
# The tool schema must never be offered for content generation.
|
|
assert tools is None
|
|
return LLMResponse(text="Voici le document traduit en anglais.", model="gpt-4o-mini")
|
|
|
|
monkeypatch.setattr(agent_router, "LLMClient", FakeLLM)
|
|
|
|
r = client.post("/api/agent/generate", json={
|
|
"prompt": "Traduis ce document en anglais.",
|
|
"context": "Titre du document : Doc\nContenu en français.",
|
|
"model": "gpt-4o-mini",
|
|
})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert "traduit en anglais" in body["text"]
|
|
|
|
|
|
def test_generate_endpoint_requires_prompt(client):
|
|
r = client.post("/api/agent/generate", json={"prompt": " "})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_mock_planner_ignores_context_keywords(client):
|
|
"""Document context words (ex: 'cherche', 'collection') must not trigger
|
|
tool intents — the planner only inspects the user objective."""
|
|
import asyncio
|
|
|
|
from app.services.llm_client import LLMClient
|
|
|
|
llm = LLMClient(provider="offline")
|
|
resp = asyncio.run(llm.complete(
|
|
[{"role": "system", "content": "x"},
|
|
{"role": "user", "content": "Résume ce document.\n\n# Contexte\n"
|
|
"Le rapport mentionne une recherche sur la collection Z."}],
|
|
model="gpt-4o", tools=[{"type": "function"}],
|
|
))
|
|
assert resp.tool_calls == []
|
|
assert resp.text
|