## Backend: agent module GRACE-Poly compliance - Split app.py (749→~280 lines) into _tool_resolver, _confirmation, _persistence - All 18 naked functions wrapped in #region/#endregion contracts - Fixed @DEFGROUP→@defgroup typos; added @DATA_CONTRACT, @SIDE_EFFECT, CoT logs - Conversation list API: added last_role, has_tool_calls, has_error, risk_level fields - Message state detection: Russian/English error patterns (недоступен, unavailable) - State field preserved in save_conversation messages - HITL titles: descriptive tool names instead of generic "HITL resume" ## Backend: conversation title generation (two-layer) - Layer 1: clean_title() — rule-based, strips file markers, pre-fetch blocks, JSON/CSV, URLs, code; truncates at 80 chars word boundary (25 unit tests, all edge cases) - Layer 2: generate_llm_title() — async best-effort LLM titling via /v1/chat/completions with per-conversation lock, graceful degradation on failure ## Frontend: conversation list indicators (orthogonal system) - Status dot (green/yellow/red/blue) per conversation state - Icon column: tool activity, errors, waiting, completed - Risk stripe (left border accent) + message count badge + relative time - Fixed group labels: "Сегодня"/"Вчера" instead of "3 ч"/"5 ч" - Hide "Окружение: —" when env is empty ## Frontend: guardrails card verification + fixes - Confirmed all interaction modes: Enter/click confirm, Escape/click deny - Auto-populate envId from environmentContextStore in DashboardDetailModel - Better error message: missing_context_hint with recovery guidance ## Design system: semantic tokens - Added category-* gradient tokens to tailwind.config.js - Sidebar + Breadcrumbs use semantic tokens (10 categories) - Raw Tailwind reduced from ~50 to 6 occurrences - Added skip-to-content link in root layout (+layout.svelte) - Added aria-label on DashboardDataGrid row checkboxes ## Protocol: INV_7 pragmatic exception - Modules may exceed 400 lines when contract-dense (every function has #region) - Recorded in semantics-core SKILL.md with rationale Total: 5841+ contracts, 2993+ edges, backend 41/41, frontend 2501/2501
366 lines
14 KiB
Python
366 lines
14 KiB
Python
# #region Test.Api.AgentConversations [C:3] [TYPE Module] [SEMANTICS test,agent,conversations,routes]
|
|
# @BRIEF Tests for agent conversation API routes — list, save, history, delete, active, llm-config.
|
|
# @RELATION BINDS_TO -> [AgentChat.Api.Conversations]
|
|
|
|
import os
|
|
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
|
os.environ.setdefault("DEV_MODE", "true")
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
|
if _src not in sys.path:
|
|
sys.path.insert(0, _src)
|
|
|
|
|
|
def _make_client(overrides: dict | None = None) -> TestClient:
|
|
from src.api.routes.agent_conversations import router, agent_router
|
|
from src.core.database import get_db
|
|
from src.dependencies import get_current_user, get_config_manager
|
|
from src.schemas.auth import User, RoleSchema
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.include_router(agent_router)
|
|
|
|
_NOW = __import__("datetime").datetime.now()
|
|
mock_user = User(
|
|
id="user-1", username="testuser", email="test@x.com",
|
|
auth_source="LOCAL",
|
|
created_at=_NOW,
|
|
roles=[RoleSchema(id="r1", name="User", description="", permissions=[])],
|
|
)
|
|
|
|
app.dependency_overrides[get_db] = lambda: MagicMock()
|
|
app.dependency_overrides[get_current_user] = lambda: mock_user
|
|
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
|
|
if overrides:
|
|
for dep, fn in overrides.items():
|
|
app.dependency_overrides[dep] = fn
|
|
return TestClient(app)
|
|
|
|
|
|
# ── list_conversations ──
|
|
|
|
class TestListConversations:
|
|
"""GET /api/assistant/conversations"""
|
|
|
|
def test_success(self):
|
|
mock_db = MagicMock()
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.count.return_value = 2
|
|
_NOW = __import__("datetime").datetime.now()
|
|
mock_msg = MagicMock()
|
|
mock_msg.role = "user"
|
|
mock_msg.tool_calls = []
|
|
mock_msg.state = None
|
|
mock_msg.text = "Hello"
|
|
mock_conv = MagicMock()
|
|
mock_conv.id = "conv-1"
|
|
mock_conv.title = "Test Conversation"
|
|
mock_conv.updated_at = _NOW
|
|
mock_conv.messages = [mock_msg]
|
|
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [mock_conv, mock_conv]
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.get("/api/assistant/conversations")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["items"]) == 2
|
|
assert data["items"][0]["id"] == "conv-1"
|
|
|
|
def test_with_search(self):
|
|
mock_db = MagicMock()
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.count.return_value = 0
|
|
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.get("/api/assistant/conversations?search=test&include_archived=true")
|
|
assert resp.status_code == 200
|
|
|
|
def test_empty(self):
|
|
mock_db = MagicMock()
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.count.return_value = 0
|
|
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.get("/api/assistant/conversations")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["items"] == []
|
|
|
|
|
|
# ── save_conversation ──
|
|
|
|
class TestSaveConversation:
|
|
"""POST /api/agent/conversations/save"""
|
|
|
|
SAVE_PAYLOAD = {
|
|
"conversation_id": "conv-1",
|
|
"title": "New Chat",
|
|
"messages": [
|
|
{"id": "msg-1", "role": "user", "text": "Hello"},
|
|
{"id": "msg-2", "role": "assistant", "text": "Hi there", "tool_calls": [{"name": "search"}]},
|
|
],
|
|
}
|
|
|
|
def test_create_new(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.post("/api/agent/conversations/save", json=self.SAVE_PAYLOAD)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["saved"] is True
|
|
assert data["conversation_id"] == "conv-1"
|
|
|
|
def test_update_existing(self):
|
|
existing_conv = MagicMock()
|
|
existing_conv.updated_at = __import__("datetime").datetime.now()
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.side_effect = [existing_conv, None, None]
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.post("/api/agent/conversations/save", json=self.SAVE_PAYLOAD)
|
|
assert resp.status_code == 200
|
|
|
|
def test_skip_empty_message_id(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.post("/api/agent/conversations/save", json={
|
|
"conversation_id": "conv-1",
|
|
"messages": [{"id": "", "role": "user", "text": "skip me"}],
|
|
})
|
|
assert resp.status_code == 200
|
|
|
|
def test_skip_duplicate_message(self):
|
|
existing_msg = MagicMock()
|
|
existing_msg.id = "msg-2"
|
|
mock_db = MagicMock()
|
|
# 3 .first() calls: 1 for conv lookup → None, msg-1 lookup → None, msg-2 lookup → existing_msg
|
|
mock_db.query.return_value.filter.return_value.first.side_effect = [None, None, existing_msg]
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.post("/api/agent/conversations/save", json=self.SAVE_PAYLOAD)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ── get_history ──
|
|
|
|
class TestGetHistory:
|
|
"""GET /api/assistant/history"""
|
|
|
|
def test_success(self):
|
|
mock_db = MagicMock()
|
|
_NOW = __import__("datetime").datetime.now()
|
|
mock_conv = MagicMock()
|
|
mock_conv.id = "conv-1"
|
|
mock_msg = MagicMock()
|
|
mock_msg.id = "msg-1"
|
|
mock_msg.conversation_id = "conv-1"
|
|
mock_msg.role = "user"
|
|
mock_msg.text = "Hello"
|
|
mock_msg.tool_calls = None
|
|
mock_msg.attachments = None
|
|
mock_msg.created_at = _NOW
|
|
mock_conv.messages = [mock_msg]
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_conv
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.get("/api/assistant/history?conversation_id=conv-1")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["text"] == "Hello"
|
|
|
|
def test_not_found(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.get("/api/assistant/history?conversation_id=unknown")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── delete_conversation ──
|
|
|
|
class TestDeleteConversation:
|
|
"""DELETE /api/assistant/conversations/{conversation_id}"""
|
|
|
|
def test_success(self):
|
|
mock_db = MagicMock()
|
|
mock_conv = MagicMock()
|
|
mock_conv.is_archived = False
|
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_conv
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.delete("/api/assistant/conversations/conv-1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["deleted"] is True
|
|
assert mock_conv.is_archived is True
|
|
|
|
def test_not_found(self):
|
|
mock_db = MagicMock()
|
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
from src.core.database import get_db
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.delete("/api/assistant/conversations/unknown")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── check_active_session ──
|
|
|
|
class TestCheckActiveSession:
|
|
"""GET /api/agent/conversations/active"""
|
|
|
|
def test_returns_false(self):
|
|
client = _make_client()
|
|
resp = client.get("/api/agent/conversations/active")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["active"] is False
|
|
|
|
|
|
# ── get_agent_llm_config ──
|
|
|
|
class TestGetAgentLlmConfig:
|
|
"""GET /api/agent/llm-config"""
|
|
|
|
def test_success_with_preferred(self):
|
|
mock_db = MagicMock()
|
|
mock_config_mgr = MagicMock()
|
|
mock_config = MagicMock()
|
|
mock_config.settings.llm = {"assistant_planner_provider": "prov-1"}
|
|
mock_config_mgr.get_config.return_value = mock_config
|
|
|
|
mock_svc = MagicMock()
|
|
mock_provider = MagicMock()
|
|
mock_provider.id = "prov-1"
|
|
mock_provider.provider_type = "openai"
|
|
mock_provider.base_url = "https://api.openai.com"
|
|
mock_provider.default_model = "gpt-4"
|
|
mock_provider.name = "GPT-4 Provider"
|
|
mock_provider.is_active = True
|
|
mock_svc.get_all_providers.return_value = [mock_provider]
|
|
mock_svc.get_decrypted_api_key.return_value = "sk-real-key"
|
|
|
|
from src.core.database import get_db
|
|
from src.dependencies import get_config_manager
|
|
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
|
client = _make_client({
|
|
get_db: lambda: mock_db,
|
|
get_config_manager: lambda: mock_config_mgr,
|
|
})
|
|
resp = client.get("/api/agent/llm-config")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["configured"] is True
|
|
assert data["provider_type"] == "openai"
|
|
|
|
def test_fallback_to_active(self):
|
|
mock_db = MagicMock()
|
|
mock_config_mgr = MagicMock()
|
|
mock_config = MagicMock()
|
|
mock_config.settings.llm = {}
|
|
mock_config_mgr.get_config.return_value = mock_config
|
|
|
|
mock_svc = MagicMock()
|
|
mock_provider = MagicMock()
|
|
mock_provider.id = "prov-2"
|
|
mock_provider.provider_type = "anthropic"
|
|
mock_provider.base_url = ""
|
|
mock_provider.default_model = "claude-3"
|
|
mock_provider.name = "Claude"
|
|
mock_provider.is_active = True
|
|
mock_svc.get_all_providers.return_value = [mock_provider]
|
|
mock_svc.get_decrypted_api_key.return_value = "sk-anthropic"
|
|
|
|
from src.core.database import get_db
|
|
from src.dependencies import get_config_manager
|
|
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
|
client = _make_client({
|
|
get_db: lambda: mock_db,
|
|
get_config_manager: lambda: mock_config_mgr,
|
|
})
|
|
resp = client.get("/api/agent/llm-config")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["configured"] is True
|
|
|
|
def test_no_active_provider(self):
|
|
mock_db = MagicMock()
|
|
mock_config_mgr = MagicMock()
|
|
mock_config = MagicMock()
|
|
mock_config.settings.llm = {}
|
|
mock_config_mgr.get_config.return_value = mock_config
|
|
|
|
mock_svc = MagicMock()
|
|
mock_svc.get_all_providers.return_value = []
|
|
|
|
from src.core.database import get_db
|
|
from src.dependencies import get_config_manager
|
|
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
|
client = _make_client({
|
|
get_db: lambda: mock_db,
|
|
get_config_manager: lambda: mock_config_mgr,
|
|
})
|
|
resp = client.get("/api/agent/llm-config")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["configured"] is False
|
|
assert resp.json()["reason"] == "no_active_provider"
|
|
|
|
def test_invalid_api_key(self):
|
|
mock_db = MagicMock()
|
|
mock_config_mgr = MagicMock()
|
|
mock_config = MagicMock()
|
|
mock_config.settings.llm = {}
|
|
mock_config_mgr.get_config.return_value = mock_config
|
|
|
|
mock_svc = MagicMock()
|
|
mock_provider = MagicMock()
|
|
mock_provider.id = "prov-1"
|
|
mock_provider.is_active = True
|
|
mock_svc.get_all_providers.return_value = [mock_provider]
|
|
mock_svc.get_decrypted_api_key.return_value = ""
|
|
|
|
from src.core.database import get_db
|
|
from src.dependencies import get_config_manager
|
|
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
|
client = _make_client({
|
|
get_db: lambda: mock_db,
|
|
get_config_manager: lambda: mock_config_mgr,
|
|
})
|
|
resp = client.get("/api/agent/llm-config")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["configured"] is False
|
|
assert resp.json()["reason"] == "invalid_api_key"
|
|
# #endregion Test.Api.AgentConversations
|