Backend: - FastAPI WebSocket /ws endpoint with streaming via LangGraph astream - LangGraph Supervisor connecting 3 mock agents (order_lookup, order_actions, fallback) - YAML Agent Registry with Pydantic validation and immutable configs - PostgresSaver checkpoint persistence via langgraph-checkpoint-postgres - Session TTL with 30-min sliding window and interrupt extension - LLM provider abstraction (Anthropic/OpenAI/Google) - Token usage + cost tracking callback handler - Input validation: message size cap, thread_id format, content length - Security: no hardcoded defaults, startup API key validation, no input reflection Frontend: - React 19 + TypeScript + Vite chat UI - WebSocket hook with reconnect + exponential backoff - Streaming token display with agent attribution - Interrupt approval/reject UI for write operations - Collapsible tool call viewer Testing: - 87 unit tests, 87% coverage (exceeds 80% requirement) - Ruff lint + format clean Infrastructure: - Docker Compose (PostgreSQL 16 + backend) - pyproject.toml with full dependency management
148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
"""Tests for app.registry module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from app.registry import AgentConfig, AgentRegistry, PersonalityConfig
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPersonalityConfig:
|
|
def test_defaults(self) -> None:
|
|
p = PersonalityConfig()
|
|
assert p.tone == "professional and helpful"
|
|
assert "Hello" in p.greeting
|
|
assert "human agent" in p.escalation_message
|
|
|
|
def test_custom_values(self) -> None:
|
|
p = PersonalityConfig(tone="casual", greeting="Hey!", escalation_message="Hold on.")
|
|
assert p.tone == "casual"
|
|
|
|
def test_immutable(self) -> None:
|
|
p = PersonalityConfig()
|
|
with pytest.raises(Exception):
|
|
p.tone = "new tone"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAgentConfig:
|
|
def test_valid_config(self) -> None:
|
|
ac = AgentConfig(
|
|
name="test",
|
|
description="A test agent",
|
|
permission="read",
|
|
tools=["tool1"],
|
|
)
|
|
assert ac.name == "test"
|
|
assert ac.permission == "read"
|
|
|
|
def test_empty_name_rejected(self) -> None:
|
|
with pytest.raises(ValueError, match="must not be empty"):
|
|
AgentConfig(name=" ", description="d", permission="read", tools=["t"])
|
|
|
|
def test_empty_tools_rejected(self) -> None:
|
|
with pytest.raises(ValueError, match="at least one tool"):
|
|
AgentConfig(name="x", description="d", permission="read", tools=[])
|
|
|
|
def test_invalid_permission_rejected(self) -> None:
|
|
with pytest.raises(Exception):
|
|
AgentConfig(name="x", description="d", permission="admin", tools=["t"])
|
|
|
|
def test_name_stripped(self) -> None:
|
|
ac = AgentConfig(name=" test ", description="d", permission="read", tools=["t"])
|
|
assert ac.name == "test"
|
|
|
|
def test_immutable(self) -> None:
|
|
ac = AgentConfig(name="test", description="d", permission="read", tools=["t"])
|
|
with pytest.raises(Exception):
|
|
ac.name = "new"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAgentRegistry:
|
|
def test_load_valid_yaml(self, sample_yaml_path: Path) -> None:
|
|
registry = AgentRegistry.load(sample_yaml_path)
|
|
assert len(registry) == 3
|
|
assert registry.get_agent("test_reader").permission == "read"
|
|
assert registry.get_agent("test_writer").permission == "write"
|
|
|
|
def test_list_agents(self, sample_registry: AgentRegistry) -> None:
|
|
agents = sample_registry.list_agents()
|
|
assert len(agents) == 3
|
|
names = {a.name for a in agents}
|
|
assert names == {"test_reader", "test_writer", "test_fallback"}
|
|
|
|
def test_get_agents_by_permission(self, sample_registry: AgentRegistry) -> None:
|
|
readers = sample_registry.get_agents_by_permission("read")
|
|
assert len(readers) == 2
|
|
writers = sample_registry.get_agents_by_permission("write")
|
|
assert len(writers) == 1
|
|
|
|
def test_get_nonexistent_agent(self, sample_registry: AgentRegistry) -> None:
|
|
with pytest.raises(KeyError, match="not found"):
|
|
sample_registry.get_agent("nonexistent")
|
|
|
|
def test_personality_defaults_applied(self, sample_registry: AgentRegistry) -> None:
|
|
agent = sample_registry.get_agent("test_reader")
|
|
assert agent.personality.tone == "professional and helpful"
|
|
|
|
def test_personality_custom_applied(self, sample_registry: AgentRegistry) -> None:
|
|
agent = sample_registry.get_agent("test_writer")
|
|
assert agent.personality.tone == "formal"
|
|
assert agent.personality.greeting == "Greetings."
|
|
|
|
def test_file_not_found(self) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
AgentRegistry.load("/nonexistent/path.yaml")
|
|
|
|
def test_empty_file(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "empty.yaml"
|
|
path.write_text("", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="empty"):
|
|
AgentRegistry.load(path)
|
|
|
|
def test_invalid_yaml_syntax(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "bad.yaml"
|
|
path.write_text("agents:\n - name: [invalid\n", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="Invalid YAML"):
|
|
AgentRegistry.load(path)
|
|
|
|
def test_missing_agents_key(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "no_agents.yaml"
|
|
path.write_text(yaml.dump({"items": []}), encoding="utf-8")
|
|
with pytest.raises(ValueError, match="agents"):
|
|
AgentRegistry.load(path)
|
|
|
|
def test_duplicate_agent_names(self, tmp_path: Path) -> None:
|
|
data = {
|
|
"agents": [
|
|
{"name": "dup", "description": "a", "permission": "read", "tools": ["t1"]},
|
|
{"name": "dup", "description": "b", "permission": "read", "tools": ["t2"]},
|
|
]
|
|
}
|
|
path = tmp_path / "dups.yaml"
|
|
path.write_text(yaml.dump(data), encoding="utf-8")
|
|
with pytest.raises(ValueError, match="Duplicate"):
|
|
AgentRegistry.load(path)
|
|
|
|
def test_missing_required_fields(self, tmp_path: Path) -> None:
|
|
data = {"agents": [{"name": "x"}]}
|
|
path = tmp_path / "missing.yaml"
|
|
path.write_text(yaml.dump(data), encoding="utf-8")
|
|
with pytest.raises(ValueError, match="Invalid agent config"):
|
|
AgentRegistry.load(path)
|
|
|
|
def test_empty_agents_list(self, tmp_path: Path) -> None:
|
|
data = {"agents": []}
|
|
path = tmp_path / "empty_list.yaml"
|
|
path.write_text(yaml.dump(data), encoding="utf-8")
|
|
with pytest.raises(ValueError, match="non-empty"):
|
|
AgentRegistry.load(path)
|