forked from cardosofelipe/pragma-stack
Compare commits
50 Commits
6954774e36
...
feature/58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a624a94af | ||
|
|
011b21bf0a | ||
|
|
76d7de5334 | ||
|
|
1779239c07 | ||
|
|
9dfa76aa41 | ||
|
|
4ad3d20cf2 | ||
|
|
8623eb56f5 | ||
|
|
3cb6c8d13b | ||
|
|
8e16e2645e | ||
|
|
82c3a6ba47 | ||
|
|
b6c38cac88 | ||
|
|
51404216ae | ||
|
|
3f23bc3db3 | ||
|
|
a0ec5fa2cc | ||
|
|
f262d08be2 | ||
|
|
b3f371e0a3 | ||
|
|
93cc37224c | ||
|
|
5717bffd63 | ||
|
|
9339ea30a1 | ||
|
|
79cb6bfd7b | ||
|
|
45025bb2f1 | ||
|
|
3c6b14d2bf | ||
|
|
6b21a6fadd | ||
|
|
600657adc4 | ||
|
|
c9d0d079b3 | ||
|
|
4c8f81368c | ||
|
|
efbe91ce14 | ||
|
|
5d646779c9 | ||
|
|
5a4d93df26 | ||
|
|
7ef217be39 | ||
|
|
20159c5865 | ||
|
|
f9a72fcb34 | ||
|
|
fcb0a5f86a | ||
|
|
92782bcb05 | ||
|
|
1dcf99ee38 | ||
|
|
70009676a3 | ||
|
|
192237e69b | ||
|
|
3edce9cd26 | ||
|
|
35aea2d73a | ||
|
|
d0f32d04f7 | ||
|
|
da85a8aba8 | ||
|
|
f8bd1011e9 | ||
|
|
f057c2f0b6 | ||
|
|
33ec889fc4 | ||
|
|
74b8c65741 | ||
|
|
b232298c61 | ||
|
|
cf6291ac8e | ||
|
|
e3fe0439fd | ||
|
|
57680c3772 | ||
|
|
997cfaa03a |
37
Makefile
37
Makefile
@@ -1,5 +1,5 @@
|
||||
.PHONY: help dev dev-full prod down logs logs-dev clean clean-slate drop-db reset-db push-images deploy
|
||||
.PHONY: test test-backend test-mcp test-frontend test-all test-cov test-integration validate validate-all
|
||||
.PHONY: test test-backend test-mcp test-frontend test-all test-cov test-integration validate validate-all format-all
|
||||
|
||||
VERSION ?= latest
|
||||
REGISTRY ?= ghcr.io/cardosofelipe/pragma-stack
|
||||
@@ -22,6 +22,9 @@ help:
|
||||
@echo " make test-cov - Run all tests with coverage reports"
|
||||
@echo " make test-integration - Run MCP integration tests (requires running stack)"
|
||||
@echo ""
|
||||
@echo "Formatting:"
|
||||
@echo " make format-all - Format code in backend + MCP servers + frontend"
|
||||
@echo ""
|
||||
@echo "Validation:"
|
||||
@echo " make validate - Validate backend + MCP servers (lint, type-check, test)"
|
||||
@echo " make validate-all - Validate everything including frontend"
|
||||
@@ -44,6 +47,7 @@ help:
|
||||
@echo " cd backend && make help - Backend-specific commands"
|
||||
@echo " cd mcp-servers/llm-gateway && make - LLM Gateway commands"
|
||||
@echo " cd mcp-servers/knowledge-base && make - Knowledge Base commands"
|
||||
@echo " cd mcp-servers/git-ops && make - Git Operations commands"
|
||||
@echo " cd frontend && npm run - Frontend-specific commands"
|
||||
|
||||
# ============================================================================
|
||||
@@ -135,6 +139,9 @@ test-mcp:
|
||||
@echo ""
|
||||
@echo "=== Knowledge Base ==="
|
||||
@cd mcp-servers/knowledge-base && uv run pytest tests/ -v
|
||||
@echo ""
|
||||
@echo "=== Git Operations ==="
|
||||
@cd mcp-servers/git-ops && IS_TEST=True uv run pytest tests/ -v
|
||||
|
||||
test-frontend:
|
||||
@echo "Running frontend tests..."
|
||||
@@ -155,12 +162,37 @@ test-cov:
|
||||
@echo ""
|
||||
@echo "=== Knowledge Base Coverage ==="
|
||||
@cd mcp-servers/knowledge-base && uv run pytest tests/ -v --cov=. --cov-report=term-missing
|
||||
@echo ""
|
||||
@echo "=== Git Operations Coverage ==="
|
||||
@cd mcp-servers/git-ops && IS_TEST=True uv run pytest tests/ -v --cov=. --cov-report=term-missing
|
||||
|
||||
test-integration:
|
||||
@echo "Running MCP integration tests..."
|
||||
@echo "Note: Requires running stack (make dev first)"
|
||||
@cd backend && RUN_INTEGRATION_TESTS=true IS_TEST=True uv run pytest tests/integration/ -v
|
||||
|
||||
# ============================================================================
|
||||
# Formatting
|
||||
# ============================================================================
|
||||
|
||||
format-all:
|
||||
@echo "Formatting backend..."
|
||||
@cd backend && make format
|
||||
@echo ""
|
||||
@echo "Formatting LLM Gateway..."
|
||||
@cd mcp-servers/llm-gateway && make format
|
||||
@echo ""
|
||||
@echo "Formatting Knowledge Base..."
|
||||
@cd mcp-servers/knowledge-base && make format
|
||||
@echo ""
|
||||
@echo "Formatting Git Operations..."
|
||||
@cd mcp-servers/git-ops && make format
|
||||
@echo ""
|
||||
@echo "Formatting frontend..."
|
||||
@cd frontend && npm run format
|
||||
@echo ""
|
||||
@echo "All code formatted!"
|
||||
|
||||
# ============================================================================
|
||||
# Validation (lint + type-check + test)
|
||||
# ============================================================================
|
||||
@@ -175,6 +207,9 @@ validate:
|
||||
@echo "Validating Knowledge Base..."
|
||||
@cd mcp-servers/knowledge-base && make validate
|
||||
@echo ""
|
||||
@echo "Validating Git Operations..."
|
||||
@cd mcp-servers/git-ops && make validate
|
||||
@echo ""
|
||||
@echo "All validations passed!"
|
||||
|
||||
validate-all: validate
|
||||
|
||||
@@ -80,7 +80,7 @@ test:
|
||||
|
||||
test-cov:
|
||||
@echo "🧪 Running tests with coverage..."
|
||||
@IS_TEST=True PYTHONPATH=. uv run pytest --cov=app --cov-report=term-missing --cov-report=html -n 16
|
||||
@IS_TEST=True PYTHONPATH=. uv run pytest --cov=app --cov-report=term-missing --cov-report=html -n 20
|
||||
@echo "📊 Coverage report generated in htmlcov/index.html"
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -247,11 +247,12 @@ def upgrade() -> None:
|
||||
sa.Column("predicate", sa.String(255), nullable=False),
|
||||
sa.Column("object", sa.Text(), nullable=False),
|
||||
sa.Column("confidence", sa.Float(), nullable=False, server_default="0.8"),
|
||||
# Source episode IDs stored as JSON array of UUID strings for cross-db compatibility
|
||||
sa.Column(
|
||||
"source_episode_ids",
|
||||
postgresql.ARRAY(postgresql.UUID(as_uuid=True)),
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
server_default="[]",
|
||||
),
|
||||
sa.Column("first_learned", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_reinforced", sa.DateTime(timezone=True), nullable=False),
|
||||
@@ -299,6 +300,14 @@ def upgrade() -> None:
|
||||
unique=True,
|
||||
postgresql_where=sa.text("project_id IS NOT NULL"),
|
||||
)
|
||||
# Unique constraint for global facts (project_id IS NULL)
|
||||
op.create_index(
|
||||
"ix_facts_unique_triple_global",
|
||||
"facts",
|
||||
["subject", "predicate", "object"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("project_id IS NULL"),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Create procedures table
|
||||
@@ -395,6 +404,11 @@ def upgrade() -> None:
|
||||
"facts",
|
||||
"confidence >= 0.0 AND confidence <= 1.0",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_facts_reinforcement_positive",
|
||||
"facts",
|
||||
"reinforcement_count >= 1",
|
||||
)
|
||||
|
||||
# Procedure constraints
|
||||
op.create_check_constraint(
|
||||
@@ -475,11 +489,15 @@ def downgrade() -> None:
|
||||
# Drop check constraints first
|
||||
op.drop_constraint("ck_procedures_failure_positive", "procedures", type_="check")
|
||||
op.drop_constraint("ck_procedures_success_positive", "procedures", type_="check")
|
||||
op.drop_constraint("ck_facts_reinforcement_positive", "facts", type_="check")
|
||||
op.drop_constraint("ck_facts_confidence_range", "facts", type_="check")
|
||||
op.drop_constraint("ck_episodes_tokens_positive", "episodes", type_="check")
|
||||
op.drop_constraint("ck_episodes_duration_positive", "episodes", type_="check")
|
||||
op.drop_constraint("ck_episodes_importance_range", "episodes", type_="check")
|
||||
|
||||
# Drop unique indexes for global facts
|
||||
op.drop_index("ix_facts_unique_triple_global", "facts")
|
||||
|
||||
# Drop tables in reverse order (dependencies first)
|
||||
op.drop_table("memory_consolidation_log")
|
||||
op.drop_table("procedures")
|
||||
|
||||
52
backend/app/alembic/versions/0006_add_abandoned_outcome.py
Normal file
52
backend/app/alembic/versions/0006_add_abandoned_outcome.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Add ABANDONED to episode_outcome enum
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2025-01-06
|
||||
|
||||
This migration adds the 'abandoned' value to the episode_outcome enum type.
|
||||
This allows episodes to track when a task was abandoned (not completed,
|
||||
but not necessarily a failure either - e.g., user cancelled, session timeout).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0006"
|
||||
down_revision: str | None = "0005"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add 'abandoned' value to episode_outcome enum."""
|
||||
# PostgreSQL ALTER TYPE ADD VALUE is safe and non-blocking
|
||||
op.execute("ALTER TYPE episode_outcome ADD VALUE IF NOT EXISTS 'abandoned'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove 'abandoned' from episode_outcome enum.
|
||||
|
||||
Note: PostgreSQL doesn't support removing values from enums directly.
|
||||
This downgrade converts any 'abandoned' episodes to 'failure' and
|
||||
recreates the enum without 'abandoned'.
|
||||
"""
|
||||
# Convert any abandoned episodes to failure first
|
||||
op.execute("""
|
||||
UPDATE episodes
|
||||
SET outcome = 'failure'
|
||||
WHERE outcome = 'abandoned'
|
||||
""")
|
||||
|
||||
# Recreate the enum without abandoned
|
||||
# This is complex in PostgreSQL - requires creating new type, updating columns, dropping old
|
||||
op.execute("ALTER TYPE episode_outcome RENAME TO episode_outcome_old")
|
||||
op.execute("CREATE TYPE episode_outcome AS ENUM ('success', 'failure', 'partial')")
|
||||
op.execute("""
|
||||
ALTER TABLE episodes
|
||||
ALTER COLUMN outcome TYPE episode_outcome
|
||||
USING outcome::text::episode_outcome
|
||||
""")
|
||||
op.execute("DROP TYPE episode_outcome_old")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Add category and display fields to agent_types table
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-01-06
|
||||
|
||||
This migration adds:
|
||||
- category: String(50) for grouping agents by role type
|
||||
- icon: String(50) for Lucide icon identifier
|
||||
- color: String(7) for hex color code
|
||||
- sort_order: Integer for display ordering within categories
|
||||
- typical_tasks: JSONB list of tasks this agent excels at
|
||||
- collaboration_hints: JSONB list of agent slugs that work well together
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0007"
|
||||
down_revision: str | None = "0006"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add category and display fields to agent_types table."""
|
||||
# Add new columns
|
||||
op.add_column(
|
||||
"agent_types",
|
||||
sa.Column("category", sa.String(length=50), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"agent_types",
|
||||
sa.Column("icon", sa.String(length=50), nullable=True, server_default="bot"),
|
||||
)
|
||||
op.add_column(
|
||||
"agent_types",
|
||||
sa.Column(
|
||||
"color", sa.String(length=7), nullable=True, server_default="#3B82F6"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"agent_types",
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"agent_types",
|
||||
sa.Column(
|
||||
"typical_tasks",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default="[]",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"agent_types",
|
||||
sa.Column(
|
||||
"collaboration_hints",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default="[]",
|
||||
),
|
||||
)
|
||||
|
||||
# Add indexes for category and sort_order
|
||||
op.create_index("ix_agent_types_category", "agent_types", ["category"])
|
||||
op.create_index("ix_agent_types_sort_order", "agent_types", ["sort_order"])
|
||||
op.create_index(
|
||||
"ix_agent_types_category_sort", "agent_types", ["category", "sort_order"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove category and display fields from agent_types table."""
|
||||
# Drop indexes
|
||||
op.drop_index("ix_agent_types_category_sort", table_name="agent_types")
|
||||
op.drop_index("ix_agent_types_sort_order", table_name="agent_types")
|
||||
op.drop_index("ix_agent_types_category", table_name="agent_types")
|
||||
|
||||
# Drop columns
|
||||
op.drop_column("agent_types", "collaboration_hints")
|
||||
op.drop_column("agent_types", "typical_tasks")
|
||||
op.drop_column("agent_types", "sort_order")
|
||||
op.drop_column("agent_types", "color")
|
||||
op.drop_column("agent_types", "icon")
|
||||
op.drop_column("agent_types", "category")
|
||||
@@ -81,6 +81,13 @@ def _build_agent_type_response(
|
||||
mcp_servers=agent_type.mcp_servers,
|
||||
tool_permissions=agent_type.tool_permissions,
|
||||
is_active=agent_type.is_active,
|
||||
# Category and display fields
|
||||
category=agent_type.category,
|
||||
icon=agent_type.icon,
|
||||
color=agent_type.color,
|
||||
sort_order=agent_type.sort_order,
|
||||
typical_tasks=agent_type.typical_tasks or [],
|
||||
collaboration_hints=agent_type.collaboration_hints or [],
|
||||
created_at=agent_type.created_at,
|
||||
updated_at=agent_type.updated_at,
|
||||
instance_count=instance_count,
|
||||
@@ -300,6 +307,7 @@ async def list_agent_types(
|
||||
request: Request,
|
||||
pagination: PaginationParams = Depends(),
|
||||
is_active: bool = Query(True, description="Filter by active status"),
|
||||
category: str | None = Query(None, description="Filter by category"),
|
||||
search: str | None = Query(None, description="Search by name, slug, description"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -314,6 +322,7 @@ async def list_agent_types(
|
||||
request: FastAPI request object
|
||||
pagination: Pagination parameters (page, limit)
|
||||
is_active: Filter by active status (default: True)
|
||||
category: Filter by category (e.g., "development", "design")
|
||||
search: Optional search term for name, slug, description
|
||||
current_user: Authenticated user
|
||||
db: Database session
|
||||
@@ -328,6 +337,7 @@ async def list_agent_types(
|
||||
skip=pagination.offset,
|
||||
limit=pagination.limit,
|
||||
is_active=is_active,
|
||||
category=category,
|
||||
search=search,
|
||||
)
|
||||
|
||||
@@ -354,6 +364,51 @@ async def list_agent_types(
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/grouped",
|
||||
response_model=dict[str, list[AgentTypeResponse]],
|
||||
summary="List Agent Types Grouped by Category",
|
||||
description="Get all agent types organized by category",
|
||||
operation_id="list_agent_types_grouped",
|
||||
)
|
||||
@limiter.limit(f"{60 * RATE_MULTIPLIER}/minute")
|
||||
async def list_agent_types_grouped(
|
||||
request: Request,
|
||||
is_active: bool = Query(True, description="Filter by active status"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Get agent types grouped by category.
|
||||
|
||||
Returns a dictionary where keys are category names and values
|
||||
are lists of agent types, sorted by sort_order within each category.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
is_active: Filter by active status (default: True)
|
||||
current_user: Authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary mapping category to list of agent types
|
||||
"""
|
||||
try:
|
||||
grouped = await agent_type_crud.get_grouped_by_category(db, is_active=is_active)
|
||||
|
||||
# Transform to response objects
|
||||
result: dict[str, list[AgentTypeResponse]] = {}
|
||||
for category, types in grouped.items():
|
||||
result[category] = [
|
||||
_build_agent_type_response(t, instance_count=0) for t in types
|
||||
]
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting grouped agent types: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{agent_type_id}",
|
||||
response_model=AgentTypeResponse,
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"name": "Acme Corp",
|
||||
"slug": "acme-corp",
|
||||
"description": "A leading provider of coyote-catching equipment."
|
||||
},
|
||||
{
|
||||
"name": "Globex Corporation",
|
||||
"slug": "globex",
|
||||
"description": "We own the East Coast."
|
||||
},
|
||||
{
|
||||
"name": "Soylent Corp",
|
||||
"slug": "soylent",
|
||||
"description": "Making food for the future."
|
||||
},
|
||||
{
|
||||
"name": "Initech",
|
||||
"slug": "initech",
|
||||
"description": "Software for the soul."
|
||||
},
|
||||
{
|
||||
"name": "Umbrella Corporation",
|
||||
"slug": "umbrella",
|
||||
"description": "Our business is life itself."
|
||||
},
|
||||
{
|
||||
"name": "Massive Dynamic",
|
||||
"slug": "massive-dynamic",
|
||||
"description": "What don't we do?"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"email": "demo@example.com",
|
||||
"password": "DemoPass1234!",
|
||||
"first_name": "Demo",
|
||||
"last_name": "User",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "alice@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Alice",
|
||||
"last_name": "Smith",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "bob@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Bob",
|
||||
"last_name": "Jones",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "charlie@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Charlie",
|
||||
"last_name": "Brown",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "diana@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Diana",
|
||||
"last_name": "Prince",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "carol@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Carol",
|
||||
"last_name": "Williams",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "owner",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "dan@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Dan",
|
||||
"last_name": "Miller",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "ellen@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Ellen",
|
||||
"last_name": "Ripley",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "fred@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Fred",
|
||||
"last_name": "Flintstone",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "dave@soylent.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Dave",
|
||||
"last_name": "Brown",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "soylent",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "gina@soylent.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Gina",
|
||||
"last_name": "Torres",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "soylent",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "harry@soylent.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Harry",
|
||||
"last_name": "Potter",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "soylent",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "eve@initech.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Eve",
|
||||
"last_name": "Davis",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "initech",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "iris@initech.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Iris",
|
||||
"last_name": "West",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "initech",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "jack@initech.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Jack",
|
||||
"last_name": "Sparrow",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "initech",
|
||||
"role": "member",
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "frank@umbrella.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Frank",
|
||||
"last_name": "Miller",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "umbrella",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "george@umbrella.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "George",
|
||||
"last_name": "Costanza",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "umbrella",
|
||||
"role": "member",
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "kate@umbrella.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Kate",
|
||||
"last_name": "Bishop",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "umbrella",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "leo@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Leo",
|
||||
"last_name": "Messi",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "owner",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "mary@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Mary",
|
||||
"last_name": "Jane",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "nathan@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Nathan",
|
||||
"last_name": "Drake",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "olivia@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Dunham",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "peter@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Peter",
|
||||
"last_name": "Parker",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "quinn@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Quinn",
|
||||
"last_name": "Mallory",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "grace@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Grace",
|
||||
"last_name": "Hopper",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "heidi@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Heidi",
|
||||
"last_name": "Klum",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "ivan@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Ivan",
|
||||
"last_name": "Drago",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "rachel@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Rachel",
|
||||
"last_name": "Green",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "sam@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Sam",
|
||||
"last_name": "Wilson",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "tony@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Tony",
|
||||
"last_name": "Stark",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "una@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Una",
|
||||
"last_name": "Chin-Riley",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "victor@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Victor",
|
||||
"last_name": "Von Doom",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "wanda@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Wanda",
|
||||
"last_name": "Maximoff",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -43,6 +43,13 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
||||
mcp_servers=obj_in.mcp_servers,
|
||||
tool_permissions=obj_in.tool_permissions,
|
||||
is_active=obj_in.is_active,
|
||||
# Category and display fields
|
||||
category=obj_in.category.value if obj_in.category else None,
|
||||
icon=obj_in.icon,
|
||||
color=obj_in.color,
|
||||
sort_order=obj_in.sort_order,
|
||||
typical_tasks=obj_in.typical_tasks,
|
||||
collaboration_hints=obj_in.collaboration_hints,
|
||||
)
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
@@ -68,6 +75,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: bool | None = None,
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
@@ -85,6 +93,9 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
||||
if is_active is not None:
|
||||
query = query.where(AgentType.is_active == is_active)
|
||||
|
||||
if category:
|
||||
query = query.where(AgentType.category == category)
|
||||
|
||||
if search:
|
||||
search_filter = or_(
|
||||
AgentType.name.ilike(f"%{search}%"),
|
||||
@@ -162,6 +173,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: bool | None = None,
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
@@ -177,6 +189,7 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
category=category,
|
||||
search=search,
|
||||
)
|
||||
|
||||
@@ -260,6 +273,44 @@ class CRUDAgentType(CRUDBase[AgentType, AgentTypeCreate, AgentTypeUpdate]):
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_grouped_by_category(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
is_active: bool = True,
|
||||
) -> dict[str, list[AgentType]]:
|
||||
"""
|
||||
Get agent types grouped by category, sorted by sort_order within each group.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
is_active: Filter by active status (default: True)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping category to list of agent types
|
||||
"""
|
||||
try:
|
||||
query = (
|
||||
select(AgentType)
|
||||
.where(AgentType.is_active == is_active)
|
||||
.order_by(AgentType.category, AgentType.sort_order, AgentType.name)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
agent_types = list(result.scalars().all())
|
||||
|
||||
# Group by category
|
||||
grouped: dict[str, list[AgentType]] = {}
|
||||
for at in agent_types:
|
||||
cat: str = str(at.category) if at.category else "uncategorized"
|
||||
if cat not in grouped:
|
||||
grouped[cat] = []
|
||||
grouped[cat].append(at)
|
||||
|
||||
return grouped
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting grouped agent types: {e!s}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
# Create a singleton instance for use across the application
|
||||
agent_type = CRUDAgentType(AgentType)
|
||||
|
||||
@@ -3,27 +3,48 @@
|
||||
Async database initialization script.
|
||||
|
||||
Creates the first superuser if configured and doesn't already exist.
|
||||
Seeds default agent types (production data) and demo data (when DEMO_MODE is enabled).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal, engine
|
||||
from app.crud.syndarix.agent_type import agent_type as agent_type_crud
|
||||
from app.crud.user import user as user_crud
|
||||
from app.models.organization import Organization
|
||||
from app.models.syndarix import AgentInstance, AgentType, Issue, Project, Sprint
|
||||
from app.models.syndarix.enums import (
|
||||
AgentStatus,
|
||||
AutonomyLevel,
|
||||
ClientMode,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectComplexity,
|
||||
ProjectStatus,
|
||||
SprintStatus,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.user_organization import UserOrganization
|
||||
from app.schemas.syndarix import AgentTypeCreate
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data file paths
|
||||
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||
DEFAULT_AGENT_TYPES_PATH = DATA_DIR / "default_agent_types.json"
|
||||
DEMO_DATA_PATH = DATA_DIR / "demo_data.json"
|
||||
|
||||
|
||||
async def init_db() -> User | None:
|
||||
"""
|
||||
@@ -54,28 +75,29 @@ async def init_db() -> User | None:
|
||||
|
||||
if existing_user:
|
||||
logger.info(f"Superuser already exists: {existing_user.email}")
|
||||
return existing_user
|
||||
else:
|
||||
# Create superuser if doesn't exist
|
||||
user_in = UserCreate(
|
||||
email=superuser_email,
|
||||
password=superuser_password,
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
is_superuser=True,
|
||||
)
|
||||
|
||||
# Create superuser if doesn't exist
|
||||
user_in = UserCreate(
|
||||
email=superuser_email,
|
||||
password=superuser_password,
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
is_superuser=True,
|
||||
)
|
||||
existing_user = await user_crud.create(session, obj_in=user_in)
|
||||
await session.commit()
|
||||
await session.refresh(existing_user)
|
||||
logger.info(f"Created first superuser: {existing_user.email}")
|
||||
|
||||
user = await user_crud.create(session, obj_in=user_in)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
# ALWAYS load default agent types (production data)
|
||||
await load_default_agent_types(session)
|
||||
|
||||
logger.info(f"Created first superuser: {user.email}")
|
||||
|
||||
# Create demo data if in demo mode
|
||||
# Only load demo data if in demo mode
|
||||
if settings.DEMO_MODE:
|
||||
await load_demo_data(session)
|
||||
|
||||
return user
|
||||
return existing_user
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
@@ -88,26 +110,96 @@ def _load_json_file(path: Path):
|
||||
return json.load(f)
|
||||
|
||||
|
||||
async def load_demo_data(session):
|
||||
"""Load demo data from JSON file."""
|
||||
demo_data_path = Path(__file__).parent / "core" / "demo_data.json"
|
||||
if not demo_data_path.exists():
|
||||
logger.warning(f"Demo data file not found: {demo_data_path}")
|
||||
async def load_default_agent_types(session: AsyncSession) -> None:
|
||||
"""
|
||||
Load default agent types from JSON file.
|
||||
|
||||
These are production defaults - created only if they don't exist, never overwritten.
|
||||
This allows users to customize agent types without worrying about server restarts.
|
||||
"""
|
||||
if not DEFAULT_AGENT_TYPES_PATH.exists():
|
||||
logger.warning(
|
||||
f"Default agent types file not found: {DEFAULT_AGENT_TYPES_PATH}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Use asyncio.to_thread to avoid blocking the event loop
|
||||
data = await asyncio.to_thread(_load_json_file, demo_data_path)
|
||||
data = await asyncio.to_thread(_load_json_file, DEFAULT_AGENT_TYPES_PATH)
|
||||
|
||||
# Create Organizations
|
||||
org_map = {}
|
||||
for org_data in data.get("organizations", []):
|
||||
# Check if org exists
|
||||
result = await session.execute(
|
||||
text("SELECT * FROM organizations WHERE slug = :slug"),
|
||||
{"slug": org_data["slug"]},
|
||||
for agent_type_data in data:
|
||||
slug = agent_type_data["slug"]
|
||||
|
||||
# Check if agent type already exists
|
||||
existing = await agent_type_crud.get_by_slug(session, slug=slug)
|
||||
|
||||
if existing:
|
||||
logger.debug(f"Agent type already exists: {agent_type_data['name']}")
|
||||
continue
|
||||
|
||||
# Create the agent type
|
||||
agent_type_in = AgentTypeCreate(
|
||||
name=agent_type_data["name"],
|
||||
slug=slug,
|
||||
description=agent_type_data.get("description"),
|
||||
expertise=agent_type_data.get("expertise", []),
|
||||
personality_prompt=agent_type_data["personality_prompt"],
|
||||
primary_model=agent_type_data["primary_model"],
|
||||
fallback_models=agent_type_data.get("fallback_models", []),
|
||||
model_params=agent_type_data.get("model_params", {}),
|
||||
mcp_servers=agent_type_data.get("mcp_servers", []),
|
||||
tool_permissions=agent_type_data.get("tool_permissions", {}),
|
||||
is_active=agent_type_data.get("is_active", True),
|
||||
# Category and display fields
|
||||
category=agent_type_data.get("category"),
|
||||
icon=agent_type_data.get("icon", "bot"),
|
||||
color=agent_type_data.get("color", "#3B82F6"),
|
||||
sort_order=agent_type_data.get("sort_order", 0),
|
||||
typical_tasks=agent_type_data.get("typical_tasks", []),
|
||||
collaboration_hints=agent_type_data.get("collaboration_hints", []),
|
||||
)
|
||||
existing_org = result.first()
|
||||
|
||||
await agent_type_crud.create(session, obj_in=agent_type_in)
|
||||
logger.info(f"Created default agent type: {agent_type_data['name']}")
|
||||
|
||||
logger.info("Default agent types loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading default agent types: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def load_demo_data(session: AsyncSession) -> None:
|
||||
"""
|
||||
Load demo data from JSON file.
|
||||
|
||||
Only runs when DEMO_MODE is enabled. Creates demo organizations, users,
|
||||
projects, sprints, agent instances, and issues.
|
||||
"""
|
||||
if not DEMO_DATA_PATH.exists():
|
||||
logger.warning(f"Demo data file not found: {DEMO_DATA_PATH}")
|
||||
return
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_load_json_file, DEMO_DATA_PATH)
|
||||
|
||||
# Build lookup maps for FK resolution
|
||||
org_map: dict[str, Organization] = {}
|
||||
user_map: dict[str, User] = {}
|
||||
project_map: dict[str, Project] = {}
|
||||
sprint_map: dict[str, Sprint] = {} # key: "project_slug:sprint_number"
|
||||
agent_type_map: dict[str, AgentType] = {}
|
||||
agent_instance_map: dict[
|
||||
str, AgentInstance
|
||||
] = {} # key: "project_slug:agent_name"
|
||||
|
||||
# ========================
|
||||
# 1. Create Organizations
|
||||
# ========================
|
||||
for org_data in data.get("organizations", []):
|
||||
org_result = await session.execute(
|
||||
select(Organization).where(Organization.slug == org_data["slug"])
|
||||
)
|
||||
existing_org = org_result.scalar_one_or_none()
|
||||
|
||||
if not existing_org:
|
||||
org = Organization(
|
||||
@@ -117,29 +209,20 @@ async def load_demo_data(session):
|
||||
is_active=True,
|
||||
)
|
||||
session.add(org)
|
||||
await session.flush() # Flush to get ID
|
||||
org_map[org.slug] = org
|
||||
await session.flush()
|
||||
org_map[str(org.slug)] = org
|
||||
logger.info(f"Created demo organization: {org.name}")
|
||||
else:
|
||||
# We can't easily get the ORM object from raw SQL result for map without querying again or mapping
|
||||
# So let's just query it properly if we need it for relationships
|
||||
# But for simplicity in this script, let's just assume we created it or it exists.
|
||||
# To properly map for users, we need the ID.
|
||||
# Let's use a simpler approach: just try to create, if slug conflict, skip.
|
||||
pass
|
||||
org_map[str(existing_org.slug)] = existing_org
|
||||
|
||||
# Re-query all orgs to build map for users
|
||||
result = await session.execute(select(Organization))
|
||||
orgs = result.scalars().all()
|
||||
org_map = {org.slug: org for org in orgs}
|
||||
|
||||
# Create Users
|
||||
# ========================
|
||||
# 2. Create Users
|
||||
# ========================
|
||||
for user_data in data.get("users", []):
|
||||
existing_user = await user_crud.get_by_email(
|
||||
session, email=user_data["email"]
|
||||
)
|
||||
if not existing_user:
|
||||
# Create user
|
||||
user_in = UserCreate(
|
||||
email=user_data["email"],
|
||||
password=user_data["password"],
|
||||
@@ -151,17 +234,13 @@ async def load_demo_data(session):
|
||||
user = await user_crud.create(session, obj_in=user_in)
|
||||
|
||||
# Randomize created_at for demo data (last 30 days)
|
||||
# This makes the charts look more realistic
|
||||
days_ago = random.randint(0, 30) # noqa: S311
|
||||
random_time = datetime.now(UTC) - timedelta(days=days_ago)
|
||||
# Add some random hours/minutes variation
|
||||
random_time = random_time.replace(
|
||||
hour=random.randint(0, 23), # noqa: S311
|
||||
minute=random.randint(0, 59), # noqa: S311
|
||||
)
|
||||
|
||||
# Update the timestamp and is_active directly in the database
|
||||
# We do this to ensure the values are persisted correctly
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE users SET created_at = :created_at, is_active = :is_active WHERE id = :user_id"
|
||||
@@ -174,7 +253,7 @@ async def load_demo_data(session):
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created demo user: {user.email} (created {days_ago} days ago, active={user_data.get('is_active', True)})"
|
||||
f"Created demo user: {user.email} (created {days_ago} days ago)"
|
||||
)
|
||||
|
||||
# Add to organization if specified
|
||||
@@ -182,19 +261,228 @@ async def load_demo_data(session):
|
||||
role = user_data.get("role")
|
||||
if org_slug and org_slug in org_map and role:
|
||||
org = org_map[org_slug]
|
||||
# Check if membership exists (it shouldn't for new user)
|
||||
member = UserOrganization(
|
||||
user_id=user.id, organization_id=org.id, role=role
|
||||
)
|
||||
session.add(member)
|
||||
logger.info(f"Added {user.email} to {org.name} as {role}")
|
||||
|
||||
user_map[str(user.email)] = user
|
||||
else:
|
||||
logger.info(f"Demo user already exists: {existing_user.email}")
|
||||
user_map[str(existing_user.email)] = existing_user
|
||||
logger.debug(f"Demo user already exists: {existing_user.email}")
|
||||
|
||||
await session.flush()
|
||||
|
||||
# Add admin user to map with special "__admin__" key
|
||||
# This allows demo data to reference the admin user as owner
|
||||
superuser_email = settings.FIRST_SUPERUSER_EMAIL or "admin@example.com"
|
||||
admin_user = await user_crud.get_by_email(session, email=superuser_email)
|
||||
if admin_user:
|
||||
user_map["__admin__"] = admin_user
|
||||
user_map[str(admin_user.email)] = admin_user
|
||||
logger.debug(f"Added admin user to map: {admin_user.email}")
|
||||
|
||||
# ========================
|
||||
# 3. Load Agent Types Map (for FK resolution)
|
||||
# ========================
|
||||
agent_types_result = await session.execute(select(AgentType))
|
||||
for at in agent_types_result.scalars().all():
|
||||
agent_type_map[str(at.slug)] = at
|
||||
|
||||
# ========================
|
||||
# 4. Create Projects
|
||||
# ========================
|
||||
for project_data in data.get("projects", []):
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.slug == project_data["slug"])
|
||||
)
|
||||
existing_project = project_result.scalar_one_or_none()
|
||||
|
||||
if not existing_project:
|
||||
# Resolve owner email to user ID
|
||||
owner_id = None
|
||||
owner_email = project_data.get("owner_email")
|
||||
if owner_email and owner_email in user_map:
|
||||
owner_id = user_map[owner_email].id
|
||||
|
||||
project = Project(
|
||||
name=project_data["name"],
|
||||
slug=project_data["slug"],
|
||||
description=project_data.get("description"),
|
||||
owner_id=owner_id,
|
||||
autonomy_level=AutonomyLevel(
|
||||
project_data.get("autonomy_level", "milestone")
|
||||
),
|
||||
status=ProjectStatus(project_data.get("status", "active")),
|
||||
complexity=ProjectComplexity(
|
||||
project_data.get("complexity", "medium")
|
||||
),
|
||||
client_mode=ClientMode(project_data.get("client_mode", "auto")),
|
||||
settings=project_data.get("settings", {}),
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
project_map[str(project.slug)] = project
|
||||
logger.info(f"Created demo project: {project.name}")
|
||||
else:
|
||||
project_map[str(existing_project.slug)] = existing_project
|
||||
logger.debug(f"Demo project already exists: {existing_project.name}")
|
||||
|
||||
# ========================
|
||||
# 5. Create Sprints
|
||||
# ========================
|
||||
for sprint_data in data.get("sprints", []):
|
||||
project_slug = sprint_data["project_slug"]
|
||||
sprint_number = sprint_data["number"]
|
||||
sprint_key = f"{project_slug}:{sprint_number}"
|
||||
|
||||
if project_slug not in project_map:
|
||||
logger.warning(f"Project not found for sprint: {project_slug}")
|
||||
continue
|
||||
|
||||
sprint_project = project_map[project_slug]
|
||||
|
||||
# Check if sprint exists
|
||||
sprint_result = await session.execute(
|
||||
select(Sprint).where(
|
||||
Sprint.project_id == sprint_project.id,
|
||||
Sprint.number == sprint_number,
|
||||
)
|
||||
)
|
||||
existing_sprint = sprint_result.scalar_one_or_none()
|
||||
|
||||
if not existing_sprint:
|
||||
sprint = Sprint(
|
||||
project_id=sprint_project.id,
|
||||
name=sprint_data["name"],
|
||||
number=sprint_number,
|
||||
goal=sprint_data.get("goal"),
|
||||
start_date=date.fromisoformat(sprint_data["start_date"]),
|
||||
end_date=date.fromisoformat(sprint_data["end_date"]),
|
||||
status=SprintStatus(sprint_data.get("status", "planned")),
|
||||
planned_points=sprint_data.get("planned_points"),
|
||||
)
|
||||
session.add(sprint)
|
||||
await session.flush()
|
||||
sprint_map[sprint_key] = sprint
|
||||
logger.info(
|
||||
f"Created demo sprint: {sprint.name} for {sprint_project.name}"
|
||||
)
|
||||
else:
|
||||
sprint_map[sprint_key] = existing_sprint
|
||||
logger.debug(f"Demo sprint already exists: {existing_sprint.name}")
|
||||
|
||||
# ========================
|
||||
# 6. Create Agent Instances
|
||||
# ========================
|
||||
for agent_data in data.get("agent_instances", []):
|
||||
project_slug = agent_data["project_slug"]
|
||||
agent_type_slug = agent_data["agent_type_slug"]
|
||||
agent_name = agent_data["name"]
|
||||
agent_key = f"{project_slug}:{agent_name}"
|
||||
|
||||
if project_slug not in project_map:
|
||||
logger.warning(f"Project not found for agent: {project_slug}")
|
||||
continue
|
||||
|
||||
if agent_type_slug not in agent_type_map:
|
||||
logger.warning(f"Agent type not found: {agent_type_slug}")
|
||||
continue
|
||||
|
||||
agent_project = project_map[project_slug]
|
||||
agent_type = agent_type_map[agent_type_slug]
|
||||
|
||||
# Check if agent instance exists (by name within project)
|
||||
agent_result = await session.execute(
|
||||
select(AgentInstance).where(
|
||||
AgentInstance.project_id == agent_project.id,
|
||||
AgentInstance.name == agent_name,
|
||||
)
|
||||
)
|
||||
existing_agent = agent_result.scalar_one_or_none()
|
||||
|
||||
if not existing_agent:
|
||||
agent_instance = AgentInstance(
|
||||
project_id=agent_project.id,
|
||||
agent_type_id=agent_type.id,
|
||||
name=agent_name,
|
||||
status=AgentStatus(agent_data.get("status", "idle")),
|
||||
current_task=agent_data.get("current_task"),
|
||||
)
|
||||
session.add(agent_instance)
|
||||
await session.flush()
|
||||
agent_instance_map[agent_key] = agent_instance
|
||||
logger.info(
|
||||
f"Created demo agent: {agent_name} ({agent_type.name}) "
|
||||
f"for {agent_project.name}"
|
||||
)
|
||||
else:
|
||||
agent_instance_map[agent_key] = existing_agent
|
||||
logger.debug(f"Demo agent already exists: {existing_agent.name}")
|
||||
|
||||
# ========================
|
||||
# 7. Create Issues
|
||||
# ========================
|
||||
for issue_data in data.get("issues", []):
|
||||
project_slug = issue_data["project_slug"]
|
||||
|
||||
if project_slug not in project_map:
|
||||
logger.warning(f"Project not found for issue: {project_slug}")
|
||||
continue
|
||||
|
||||
issue_project = project_map[project_slug]
|
||||
|
||||
# Check if issue exists (by title within project - simple heuristic)
|
||||
issue_result = await session.execute(
|
||||
select(Issue).where(
|
||||
Issue.project_id == issue_project.id,
|
||||
Issue.title == issue_data["title"],
|
||||
)
|
||||
)
|
||||
existing_issue = issue_result.scalar_one_or_none()
|
||||
|
||||
if not existing_issue:
|
||||
# Resolve sprint
|
||||
sprint_id = None
|
||||
sprint_number = issue_data.get("sprint_number")
|
||||
if sprint_number:
|
||||
sprint_key = f"{project_slug}:{sprint_number}"
|
||||
if sprint_key in sprint_map:
|
||||
sprint_id = sprint_map[sprint_key].id
|
||||
|
||||
# Resolve assigned agent
|
||||
assigned_agent_id = None
|
||||
assigned_agent_name = issue_data.get("assigned_agent_name")
|
||||
if assigned_agent_name:
|
||||
agent_key = f"{project_slug}:{assigned_agent_name}"
|
||||
if agent_key in agent_instance_map:
|
||||
assigned_agent_id = agent_instance_map[agent_key].id
|
||||
|
||||
issue = Issue(
|
||||
project_id=issue_project.id,
|
||||
sprint_id=sprint_id,
|
||||
type=IssueType(issue_data.get("type", "task")),
|
||||
title=issue_data["title"],
|
||||
body=issue_data.get("body", ""),
|
||||
status=IssueStatus(issue_data.get("status", "open")),
|
||||
priority=IssuePriority(issue_data.get("priority", "medium")),
|
||||
labels=issue_data.get("labels", []),
|
||||
story_points=issue_data.get("story_points"),
|
||||
assigned_agent_id=assigned_agent_id,
|
||||
)
|
||||
session.add(issue)
|
||||
logger.info(f"Created demo issue: {issue.title[:50]}...")
|
||||
else:
|
||||
logger.debug(
|
||||
f"Demo issue already exists: {existing_issue.title[:50]}..."
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
logger.info("Demo data loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Error loading demo data: {e}")
|
||||
raise
|
||||
|
||||
@@ -210,12 +498,12 @@ async def main():
|
||||
try:
|
||||
user = await init_db()
|
||||
if user:
|
||||
print("✓ Database initialized successfully")
|
||||
print(f"✓ Superuser: {user.email}")
|
||||
print("Database initialized successfully")
|
||||
print(f"Superuser: {user.email}")
|
||||
else:
|
||||
print("✗ Failed to initialize database")
|
||||
print("Failed to initialize database")
|
||||
except Exception as e:
|
||||
print(f"✗ Error initializing database: {e}")
|
||||
print(f"Error initializing database: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Close the engine
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy import (
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import (
|
||||
ARRAY,
|
||||
JSONB,
|
||||
UUID as PGUUID,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
@@ -63,10 +63,8 @@ class Fact(Base, UUIDMixin, TimestampMixin):
|
||||
# Confidence score (0.0 to 1.0)
|
||||
confidence = Column(Float, nullable=False, default=0.8, index=True)
|
||||
|
||||
# Source tracking: which episodes contributed to this fact
|
||||
source_episode_ids: Column[list] = Column(
|
||||
ARRAY(PGUUID(as_uuid=True)), default=list, nullable=False
|
||||
)
|
||||
# Source tracking: which episodes contributed to this fact (stored as JSONB array of UUID strings)
|
||||
source_episode_ids: Column[list] = Column(JSONB, default=list, nullable=False)
|
||||
|
||||
# Learning history
|
||||
first_learned = Column(DateTime(timezone=True), nullable=False)
|
||||
@@ -90,17 +88,29 @@ class Fact(Base, UUIDMixin, TimestampMixin):
|
||||
unique=True,
|
||||
postgresql_where=text("project_id IS NOT NULL"),
|
||||
),
|
||||
# Unique constraint on triple for global facts (project_id IS NULL)
|
||||
Index(
|
||||
"ix_facts_unique_triple_global",
|
||||
"subject",
|
||||
"predicate",
|
||||
"object",
|
||||
unique=True,
|
||||
postgresql_where=text("project_id IS NULL"),
|
||||
),
|
||||
# Query patterns
|
||||
Index("ix_facts_subject_predicate", "subject", "predicate"),
|
||||
Index("ix_facts_project_subject", "project_id", "subject"),
|
||||
Index("ix_facts_confidence_time", "confidence", "last_reinforced"),
|
||||
# For finding facts by entity (subject or object)
|
||||
Index("ix_facts_subject", "subject"),
|
||||
# Note: subject already has index=True on Column definition, no need for explicit index
|
||||
# Data integrity constraints
|
||||
CheckConstraint(
|
||||
"confidence >= 0.0 AND confidence <= 1.0",
|
||||
name="ck_facts_confidence_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"reinforcement_count >= 1",
|
||||
name="ck_facts_reinforcement_positive",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -62,7 +62,11 @@ class AgentInstance(Base, UUIDMixin, TimestampMixin):
|
||||
|
||||
# Status tracking
|
||||
status: Column[AgentStatus] = Column(
|
||||
Enum(AgentStatus),
|
||||
Enum(
|
||||
AgentStatus,
|
||||
name="agent_status",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=AgentStatus.IDLE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
|
||||
@@ -6,7 +6,7 @@ An AgentType is a template that defines the capabilities, personality,
|
||||
and model configuration for agent instances.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, Index, String, Text
|
||||
from sqlalchemy import Boolean, Column, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
@@ -56,6 +56,24 @@ class AgentType(Base, UUIDMixin, TimestampMixin):
|
||||
# Whether this agent type is available for new instances
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
|
||||
# Category for grouping agents (development, design, quality, etc.)
|
||||
category = Column(String(50), nullable=True, index=True)
|
||||
|
||||
# Lucide icon identifier for UI display (e.g., "code", "palette", "shield")
|
||||
icon = Column(String(50), nullable=True, default="bot")
|
||||
|
||||
# Hex color code for visual distinction (e.g., "#3B82F6")
|
||||
color = Column(String(7), nullable=True, default="#3B82F6")
|
||||
|
||||
# Display ordering within category (lower = first)
|
||||
sort_order = Column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
# List of typical tasks this agent excels at
|
||||
typical_tasks = Column(JSONB, default=list, nullable=False)
|
||||
|
||||
# List of agent slugs that collaborate well with this type
|
||||
collaboration_hints = Column(JSONB, default=list, nullable=False)
|
||||
|
||||
# Relationships
|
||||
instances = relationship(
|
||||
"AgentInstance",
|
||||
@@ -66,6 +84,7 @@ class AgentType(Base, UUIDMixin, TimestampMixin):
|
||||
__table_args__ = (
|
||||
Index("ix_agent_types_slug_active", "slug", "is_active"),
|
||||
Index("ix_agent_types_name_active", "name", "is_active"),
|
||||
Index("ix_agent_types_category_sort", "category", "sort_order"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -167,3 +167,29 @@ class SprintStatus(str, PyEnum):
|
||||
IN_REVIEW = "in_review"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class AgentTypeCategory(str, PyEnum):
|
||||
"""
|
||||
Category classification for agent types.
|
||||
|
||||
Used for grouping and filtering agents in the UI.
|
||||
|
||||
DEVELOPMENT: Product, project, and engineering roles
|
||||
DESIGN: UI/UX and design research roles
|
||||
QUALITY: QA and security engineering
|
||||
OPERATIONS: DevOps and MLOps
|
||||
AI_ML: Machine learning and AI specialists
|
||||
DATA: Data science and engineering
|
||||
LEADERSHIP: Technical leadership roles
|
||||
DOMAIN_EXPERT: Industry and domain specialists
|
||||
"""
|
||||
|
||||
DEVELOPMENT = "development"
|
||||
DESIGN = "design"
|
||||
QUALITY = "quality"
|
||||
OPERATIONS = "operations"
|
||||
AI_ML = "ai_ml"
|
||||
DATA = "data"
|
||||
LEADERSHIP = "leadership"
|
||||
DOMAIN_EXPERT = "domain_expert"
|
||||
|
||||
@@ -59,7 +59,9 @@ class Issue(Base, UUIDMixin, TimestampMixin):
|
||||
|
||||
# Issue type (Epic, Story, Task, Bug)
|
||||
type: Column[IssueType] = Column(
|
||||
Enum(IssueType),
|
||||
Enum(
|
||||
IssueType, name="issue_type", values_callable=lambda x: [e.value for e in x]
|
||||
),
|
||||
default=IssueType.TASK,
|
||||
nullable=False,
|
||||
index=True,
|
||||
@@ -78,14 +80,22 @@ class Issue(Base, UUIDMixin, TimestampMixin):
|
||||
|
||||
# Status and priority
|
||||
status: Column[IssueStatus] = Column(
|
||||
Enum(IssueStatus),
|
||||
Enum(
|
||||
IssueStatus,
|
||||
name="issue_status",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=IssueStatus.OPEN,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
priority: Column[IssuePriority] = Column(
|
||||
Enum(IssuePriority),
|
||||
Enum(
|
||||
IssuePriority,
|
||||
name="issue_priority",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=IssuePriority.MEDIUM,
|
||||
nullable=False,
|
||||
index=True,
|
||||
@@ -132,7 +142,11 @@ class Issue(Base, UUIDMixin, TimestampMixin):
|
||||
|
||||
# Sync status with external tracker
|
||||
sync_status: Column[SyncStatus] = Column(
|
||||
Enum(SyncStatus),
|
||||
Enum(
|
||||
SyncStatus,
|
||||
name="sync_status",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=SyncStatus.SYNCED,
|
||||
nullable=False,
|
||||
# Note: Index defined in __table_args__ as ix_issues_sync_status
|
||||
|
||||
@@ -35,28 +35,44 @@ class Project(Base, UUIDMixin, TimestampMixin):
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
autonomy_level: Column[AutonomyLevel] = Column(
|
||||
Enum(AutonomyLevel),
|
||||
Enum(
|
||||
AutonomyLevel,
|
||||
name="autonomy_level",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=AutonomyLevel.MILESTONE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
status: Column[ProjectStatus] = Column(
|
||||
Enum(ProjectStatus),
|
||||
Enum(
|
||||
ProjectStatus,
|
||||
name="project_status",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=ProjectStatus.ACTIVE,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
complexity: Column[ProjectComplexity] = Column(
|
||||
Enum(ProjectComplexity),
|
||||
Enum(
|
||||
ProjectComplexity,
|
||||
name="project_complexity",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=ProjectComplexity.MEDIUM,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
client_mode: Column[ClientMode] = Column(
|
||||
Enum(ClientMode),
|
||||
Enum(
|
||||
ClientMode,
|
||||
name="client_mode",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=ClientMode.AUTO,
|
||||
nullable=False,
|
||||
index=True,
|
||||
|
||||
@@ -57,7 +57,11 @@ class Sprint(Base, UUIDMixin, TimestampMixin):
|
||||
|
||||
# Status
|
||||
status: Column[SprintStatus] = Column(
|
||||
Enum(SprintStatus),
|
||||
Enum(
|
||||
SprintStatus,
|
||||
name="sprint_status",
|
||||
values_callable=lambda x: [e.value for e in x],
|
||||
),
|
||||
default=SprintStatus.PLANNED,
|
||||
nullable=False,
|
||||
index=True,
|
||||
|
||||
@@ -10,6 +10,8 @@ from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.models.syndarix.enums import AgentTypeCategory
|
||||
|
||||
|
||||
class AgentTypeBase(BaseModel):
|
||||
"""Base agent type schema with common fields."""
|
||||
@@ -26,6 +28,14 @@ class AgentTypeBase(BaseModel):
|
||||
tool_permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
is_active: bool = True
|
||||
|
||||
# Category and display fields
|
||||
category: AgentTypeCategory | None = None
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
sort_order: int = Field(default=0, ge=0, le=1000)
|
||||
typical_tasks: list[str] = Field(default_factory=list)
|
||||
collaboration_hints: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, v: str | None) -> str | None:
|
||||
@@ -62,6 +72,18 @@ class AgentTypeBase(BaseModel):
|
||||
"""Validate MCP server list."""
|
||||
return [s.strip() for s in v if s.strip()]
|
||||
|
||||
@field_validator("typical_tasks")
|
||||
@classmethod
|
||||
def validate_typical_tasks(cls, v: list[str]) -> list[str]:
|
||||
"""Validate and normalize typical tasks list."""
|
||||
return [t.strip() for t in v if t.strip()]
|
||||
|
||||
@field_validator("collaboration_hints")
|
||||
@classmethod
|
||||
def validate_collaboration_hints(cls, v: list[str]) -> list[str]:
|
||||
"""Validate and normalize collaboration hints (agent slugs)."""
|
||||
return [h.strip().lower() for h in v if h.strip()]
|
||||
|
||||
|
||||
class AgentTypeCreate(AgentTypeBase):
|
||||
"""Schema for creating a new agent type."""
|
||||
@@ -87,6 +109,14 @@ class AgentTypeUpdate(BaseModel):
|
||||
tool_permissions: dict[str, Any] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
# Category and display fields (all optional for updates)
|
||||
category: AgentTypeCategory | None = None
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
sort_order: int | None = Field(None, ge=0, le=1000)
|
||||
typical_tasks: list[str] | None = None
|
||||
collaboration_hints: list[str] | None = None
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, v: str | None) -> str | None:
|
||||
@@ -119,6 +149,22 @@ class AgentTypeUpdate(BaseModel):
|
||||
return v
|
||||
return [e.strip().lower() for e in v if e.strip()]
|
||||
|
||||
@field_validator("typical_tasks")
|
||||
@classmethod
|
||||
def validate_typical_tasks(cls, v: list[str] | None) -> list[str] | None:
|
||||
"""Validate and normalize typical tasks list."""
|
||||
if v is None:
|
||||
return v
|
||||
return [t.strip() for t in v if t.strip()]
|
||||
|
||||
@field_validator("collaboration_hints")
|
||||
@classmethod
|
||||
def validate_collaboration_hints(cls, v: list[str] | None) -> list[str] | None:
|
||||
"""Validate and normalize collaboration hints (agent slugs)."""
|
||||
if v is None:
|
||||
return v
|
||||
return [h.strip().lower() for h in v if h.strip()]
|
||||
|
||||
|
||||
class AgentTypeInDB(AgentTypeBase):
|
||||
"""Schema for agent type in database."""
|
||||
|
||||
@@ -344,7 +344,12 @@ class BudgetAllocator:
|
||||
Rebalanced budget
|
||||
"""
|
||||
if prioritize is None:
|
||||
prioritize = [ContextType.KNOWLEDGE, ContextType.MEMORY, ContextType.TASK, ContextType.SYSTEM]
|
||||
prioritize = [
|
||||
ContextType.KNOWLEDGE,
|
||||
ContextType.MEMORY,
|
||||
ContextType.TASK,
|
||||
ContextType.SYSTEM,
|
||||
]
|
||||
|
||||
# Calculate unused tokens per type
|
||||
unused: dict[str, int] = {}
|
||||
|
||||
@@ -122,16 +122,24 @@ class MCPClientManager:
|
||||
)
|
||||
|
||||
async def _connect_all_servers(self) -> None:
|
||||
"""Connect to all enabled MCP servers."""
|
||||
"""Connect to all enabled MCP servers concurrently."""
|
||||
import asyncio
|
||||
|
||||
enabled_servers = self._registry.get_enabled_configs()
|
||||
|
||||
for name, config in enabled_servers.items():
|
||||
async def connect_server(name: str, config: "MCPServerConfig") -> None:
|
||||
try:
|
||||
await self._pool.get_connection(name, config)
|
||||
logger.info("Connected to MCP server: %s", name)
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to MCP server %s: %s", name, e)
|
||||
|
||||
# Connect to all servers concurrently for faster startup
|
||||
await asyncio.gather(
|
||||
*(connect_server(name, config) for name, config in enabled_servers.items()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""
|
||||
Shutdown the MCP client manager.
|
||||
|
||||
@@ -179,6 +179,8 @@ def load_mcp_config(path: str | Path | None = None) -> MCPConfig:
|
||||
2. MCP_CONFIG_PATH environment variable
|
||||
3. Default path (backend/mcp_servers.yaml)
|
||||
4. Empty config if no file exists
|
||||
|
||||
In test mode (IS_TEST=True), retry settings are reduced for faster tests.
|
||||
"""
|
||||
if path is None:
|
||||
path = os.environ.get("MCP_CONFIG_PATH", str(DEFAULT_CONFIG_PATH))
|
||||
@@ -189,7 +191,18 @@ def load_mcp_config(path: str | Path | None = None) -> MCPConfig:
|
||||
# Return empty config if no file exists (allows runtime registration)
|
||||
return MCPConfig()
|
||||
|
||||
return MCPConfig.from_yaml(path)
|
||||
config = MCPConfig.from_yaml(path)
|
||||
|
||||
# In test mode, reduce retry settings to speed up tests
|
||||
is_test = os.environ.get("IS_TEST", "").lower() in ("true", "1", "yes")
|
||||
if is_test:
|
||||
for server_config in config.mcp_servers.values():
|
||||
server_config.retry_attempts = 1 # Single attempt
|
||||
server_config.retry_delay = 0.1 # 100ms instead of 1s
|
||||
server_config.retry_max_delay = 0.5 # 500ms max
|
||||
server_config.timeout = 2 # 2s timeout instead of 30-120s
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def create_default_config() -> MCPConfig:
|
||||
|
||||
@@ -90,6 +90,9 @@ from .types import (
|
||||
WorkingMemoryItem,
|
||||
)
|
||||
|
||||
# Reflection (lazy import available)
|
||||
# Import directly: from app.services.memory.reflection import MemoryReflection
|
||||
|
||||
__all__ = [
|
||||
"CheckpointError",
|
||||
"ConsolidationStatus",
|
||||
|
||||
@@ -50,7 +50,9 @@ class CacheStats:
|
||||
"embedding_cache": self.embedding_cache,
|
||||
"retrieval_cache": self.retrieval_cache,
|
||||
"overall_hit_rate": self.overall_hit_rate,
|
||||
"last_cleanup": self.last_cleanup.isoformat() if self.last_cleanup else None,
|
||||
"last_cleanup": self.last_cleanup.isoformat()
|
||||
if self.last_cleanup
|
||||
else None,
|
||||
"cleanup_count": self.cleanup_count,
|
||||
}
|
||||
|
||||
@@ -104,7 +106,8 @@ class CacheManager:
|
||||
else:
|
||||
self._embedding_cache = create_embedding_cache(
|
||||
max_size=self._settings.cache_max_items,
|
||||
default_ttl_seconds=self._settings.cache_ttl_seconds * 12, # 1hr for embeddings
|
||||
default_ttl_seconds=self._settings.cache_ttl_seconds
|
||||
* 12, # 1hr for embeddings
|
||||
redis=redis,
|
||||
)
|
||||
|
||||
@@ -271,7 +274,9 @@ class CacheManager:
|
||||
|
||||
# Invalidate retrieval cache
|
||||
if self._retrieval_cache:
|
||||
uuid_id = UUID(str(memory_id)) if not isinstance(memory_id, UUID) else memory_id
|
||||
uuid_id = (
|
||||
UUID(str(memory_id)) if not isinstance(memory_id, UUID) else memory_id
|
||||
)
|
||||
count += self._retrieval_cache.invalidate_by_memory(uuid_id)
|
||||
|
||||
logger.debug(f"Invalidated {count} cache entries for {memory_type}:{memory_id}")
|
||||
|
||||
@@ -405,9 +405,7 @@ class EmbeddingCache:
|
||||
count = 0
|
||||
|
||||
with self._lock:
|
||||
keys_to_remove = [
|
||||
k for k, v in self._cache.items() if v.model == model
|
||||
]
|
||||
keys_to_remove = [k for k, v in self._cache.items() if v.model == model]
|
||||
for key in keys_to_remove:
|
||||
del self._cache[key]
|
||||
count += 1
|
||||
@@ -454,9 +452,7 @@ class EmbeddingCache:
|
||||
Number of entries removed
|
||||
"""
|
||||
with self._lock:
|
||||
keys_to_remove = [
|
||||
k for k, v in self._cache.items() if v.is_expired()
|
||||
]
|
||||
keys_to_remove = [k for k, v in self._cache.items() if v.is_expired()]
|
||||
for key in keys_to_remove:
|
||||
del self._cache[key]
|
||||
self._stats.expirations += 1
|
||||
|
||||
@@ -384,9 +384,7 @@ class HotMemoryCache[T]:
|
||||
Number of entries removed
|
||||
"""
|
||||
with self._lock:
|
||||
keys_to_remove = [
|
||||
k for k, v in self._cache.items() if v.is_expired()
|
||||
]
|
||||
keys_to_remove = [k for k, v in self._cache.items() if v.is_expired()]
|
||||
for key in keys_to_remove:
|
||||
del self._cache[key]
|
||||
self._stats.expirations += 1
|
||||
|
||||
@@ -892,27 +892,22 @@ class MemoryConsolidationService:
|
||||
return result
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_consolidation_service: MemoryConsolidationService | None = None
|
||||
|
||||
|
||||
# Factory function - no singleton to avoid stale session issues
|
||||
async def get_consolidation_service(
|
||||
session: AsyncSession,
|
||||
config: ConsolidationConfig | None = None,
|
||||
) -> MemoryConsolidationService:
|
||||
"""
|
||||
Get or create the memory consolidation service.
|
||||
Create a memory consolidation service for the given session.
|
||||
|
||||
Note: This creates a new instance each time to avoid stale session issues.
|
||||
The service is lightweight and safe to recreate per-request.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
session: Database session (must be active)
|
||||
config: Optional configuration
|
||||
|
||||
Returns:
|
||||
MemoryConsolidationService instance
|
||||
"""
|
||||
global _consolidation_service
|
||||
if _consolidation_service is None:
|
||||
_consolidation_service = MemoryConsolidationService(
|
||||
session=session, config=config
|
||||
)
|
||||
return _consolidation_service
|
||||
return MemoryConsolidationService(session=session, config=config)
|
||||
|
||||
@@ -197,10 +197,17 @@ class VectorIndex(MemoryIndex[T]):
|
||||
results = [(s, e) for s, e in results if e.memory_type == memory_type]
|
||||
|
||||
# Store similarity in metadata for the returned entries
|
||||
# Use a copy of metadata to avoid mutating cached entries
|
||||
output = []
|
||||
for similarity, entry in results[:limit]:
|
||||
entry.metadata["similarity"] = similarity
|
||||
output.append(entry)
|
||||
# Create a shallow copy of the entry with updated metadata
|
||||
entry_with_score = VectorIndexEntry(
|
||||
memory_id=entry.memory_id,
|
||||
memory_type=entry.memory_type,
|
||||
embedding=entry.embedding,
|
||||
metadata={**entry.metadata, "similarity": similarity},
|
||||
)
|
||||
output.append(entry_with_score)
|
||||
|
||||
logger.debug(f"Vector search returned {len(output)} results")
|
||||
return output
|
||||
|
||||
@@ -13,6 +13,7 @@ Provides hybrid retrieval capabilities combining:
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, TypeVar
|
||||
@@ -243,7 +244,8 @@ class RetrievalCache:
|
||||
"""
|
||||
In-memory cache for retrieval results.
|
||||
|
||||
Supports TTL-based expiration and LRU eviction.
|
||||
Supports TTL-based expiration and LRU eviction with O(1) operations.
|
||||
Uses OrderedDict for efficient LRU tracking.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -258,10 +260,10 @@ class RetrievalCache:
|
||||
max_entries: Maximum cache entries
|
||||
default_ttl_seconds: Default TTL for entries
|
||||
"""
|
||||
self._cache: dict[str, CacheEntry] = {}
|
||||
# OrderedDict maintains insertion order; we use move_to_end for O(1) LRU
|
||||
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
|
||||
self._max_entries = max_entries
|
||||
self._default_ttl = default_ttl_seconds
|
||||
self._access_order: list[str] = []
|
||||
logger.info(
|
||||
f"Initialized RetrievalCache with max_entries={max_entries}, "
|
||||
f"ttl={default_ttl_seconds}s"
|
||||
@@ -283,14 +285,10 @@ class RetrievalCache:
|
||||
entry = self._cache[query_key]
|
||||
if entry.is_expired():
|
||||
del self._cache[query_key]
|
||||
if query_key in self._access_order:
|
||||
self._access_order.remove(query_key)
|
||||
return None
|
||||
|
||||
# Update access order (LRU)
|
||||
if query_key in self._access_order:
|
||||
self._access_order.remove(query_key)
|
||||
self._access_order.append(query_key)
|
||||
# Update access order (LRU) - O(1) with OrderedDict
|
||||
self._cache.move_to_end(query_key)
|
||||
|
||||
logger.debug(f"Cache hit for {query_key}")
|
||||
return entry.results
|
||||
@@ -309,11 +307,9 @@ class RetrievalCache:
|
||||
results: Results to cache
|
||||
ttl_seconds: TTL for this entry (or default)
|
||||
"""
|
||||
# Evict if at capacity
|
||||
while len(self._cache) >= self._max_entries and self._access_order:
|
||||
oldest_key = self._access_order.pop(0)
|
||||
if oldest_key in self._cache:
|
||||
del self._cache[oldest_key]
|
||||
# Evict oldest entries if at capacity - O(1) with popitem(last=False)
|
||||
while len(self._cache) >= self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
entry = CacheEntry(
|
||||
results=results,
|
||||
@@ -323,7 +319,6 @@ class RetrievalCache:
|
||||
)
|
||||
|
||||
self._cache[query_key] = entry
|
||||
self._access_order.append(query_key)
|
||||
logger.debug(f"Cached {len(results)} results for {query_key}")
|
||||
|
||||
def invalidate(self, query_key: str) -> bool:
|
||||
@@ -338,8 +333,6 @@ class RetrievalCache:
|
||||
"""
|
||||
if query_key in self._cache:
|
||||
del self._cache[query_key]
|
||||
if query_key in self._access_order:
|
||||
self._access_order.remove(query_key)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -376,7 +369,6 @@ class RetrievalCache:
|
||||
"""
|
||||
count = len(self._cache)
|
||||
self._cache.clear()
|
||||
self._access_order.clear()
|
||||
logger.info(f"Cleared {count} cache entries")
|
||||
return count
|
||||
|
||||
|
||||
@@ -321,10 +321,7 @@ class MemoryContextSource:
|
||||
min_confidence=min_relevance,
|
||||
)
|
||||
|
||||
return [
|
||||
MemoryContext.from_semantic_memory(fact, query=query)
|
||||
for fact in facts
|
||||
]
|
||||
return [MemoryContext.from_semantic_memory(fact, query=query) for fact in facts]
|
||||
|
||||
async def _fetch_procedural(
|
||||
self,
|
||||
|
||||
@@ -287,7 +287,9 @@ class AgentLifecycleManager:
|
||||
# Get all current state
|
||||
all_keys = await working.list_keys()
|
||||
# Filter out checkpoint keys
|
||||
state_keys = [k for k in all_keys if not k.startswith(self.CHECKPOINT_PREFIX)]
|
||||
state_keys = [
|
||||
k for k in all_keys if not k.startswith(self.CHECKPOINT_PREFIX)
|
||||
]
|
||||
|
||||
state: dict[str, Any] = {}
|
||||
for key in state_keys:
|
||||
@@ -483,7 +485,9 @@ class AgentLifecycleManager:
|
||||
|
||||
# Gather session state for consolidation
|
||||
all_keys = await working.list_keys()
|
||||
state_keys = [k for k in all_keys if not k.startswith(self.CHECKPOINT_PREFIX)]
|
||||
state_keys = [
|
||||
k for k in all_keys if not k.startswith(self.CHECKPOINT_PREFIX)
|
||||
]
|
||||
|
||||
session_state: dict[str, Any] = {}
|
||||
for key in state_keys:
|
||||
@@ -597,14 +601,16 @@ class AgentLifecycleManager:
|
||||
|
||||
for key in all_keys:
|
||||
if key.startswith(self.CHECKPOINT_PREFIX):
|
||||
checkpoint_id = key[len(self.CHECKPOINT_PREFIX):]
|
||||
checkpoint_id = key[len(self.CHECKPOINT_PREFIX) :]
|
||||
checkpoint = await working.get(key)
|
||||
if checkpoint:
|
||||
checkpoints.append({
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"timestamp": checkpoint.get("timestamp"),
|
||||
"keys_count": checkpoint.get("keys_count", 0),
|
||||
})
|
||||
checkpoints.append(
|
||||
{
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"timestamp": checkpoint.get("timestamp"),
|
||||
"keys_count": checkpoint.get("keys_count", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
checkpoints.sort(
|
||||
|
||||
@@ -7,6 +7,7 @@ All tools are scoped to project/agent context for proper isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -83,6 +84,9 @@ class MemoryToolService:
|
||||
This service coordinates between different memory types.
|
||||
"""
|
||||
|
||||
# Maximum number of working memory sessions to cache (LRU eviction)
|
||||
MAX_WORKING_SESSIONS = 1000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -98,8 +102,8 @@ class MemoryToolService:
|
||||
self._session = session
|
||||
self._embedding_generator = embedding_generator
|
||||
|
||||
# Lazy-initialized memory services
|
||||
self._working: dict[str, WorkingMemory] = {} # keyed by session_id
|
||||
# Lazy-initialized memory services with LRU eviction for working memory
|
||||
self._working: OrderedDict[str, WorkingMemory] = OrderedDict()
|
||||
self._episodic: EpisodicMemory | None = None
|
||||
self._semantic: SemanticMemory | None = None
|
||||
self._procedural: ProceduralMemory | None = None
|
||||
@@ -110,14 +114,28 @@ class MemoryToolService:
|
||||
project_id: UUID | None = None,
|
||||
agent_instance_id: UUID | None = None,
|
||||
) -> WorkingMemory:
|
||||
"""Get or create working memory for a session."""
|
||||
if session_id not in self._working:
|
||||
self._working[session_id] = await WorkingMemory.for_session(
|
||||
session_id=session_id,
|
||||
project_id=str(project_id) if project_id else None,
|
||||
agent_instance_id=str(agent_instance_id) if agent_instance_id else None,
|
||||
)
|
||||
return self._working[session_id]
|
||||
"""Get or create working memory for a session with LRU eviction."""
|
||||
if session_id in self._working:
|
||||
# Move to end (most recently used)
|
||||
self._working.move_to_end(session_id)
|
||||
return self._working[session_id]
|
||||
|
||||
# Evict oldest entries if at capacity
|
||||
while len(self._working) >= self.MAX_WORKING_SESSIONS:
|
||||
oldest_id, oldest_memory = self._working.popitem(last=False)
|
||||
try:
|
||||
await oldest_memory.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing evicted working memory {oldest_id}: {e}")
|
||||
|
||||
# Create new working memory
|
||||
working = await WorkingMemory.for_session(
|
||||
session_id=session_id,
|
||||
project_id=str(project_id) if project_id else None,
|
||||
agent_instance_id=str(agent_instance_id) if agent_instance_id else None,
|
||||
)
|
||||
self._working[session_id] = working
|
||||
return working
|
||||
|
||||
async def _get_episodic(self) -> EpisodicMemory:
|
||||
"""Get or create episodic memory service."""
|
||||
@@ -414,12 +432,14 @@ class MemoryToolService:
|
||||
if args.query.lower() in key.lower():
|
||||
value = await working.get(key)
|
||||
if value is not None:
|
||||
results.append({
|
||||
"type": "working",
|
||||
"key": key,
|
||||
"content": str(value),
|
||||
"relevance": 1.0,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"type": "working",
|
||||
"key": key,
|
||||
"content": str(value),
|
||||
"relevance": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
elif memory_type == MemoryType.EPISODIC:
|
||||
episodic = await self._get_episodic()
|
||||
@@ -430,14 +450,18 @@ class MemoryToolService:
|
||||
agent_instance_id=context.agent_instance_id,
|
||||
)
|
||||
for episode in episodes:
|
||||
results.append({
|
||||
"type": "episodic",
|
||||
"id": str(episode.id),
|
||||
"summary": episode.task_description,
|
||||
"outcome": episode.outcome.value if episode.outcome else None,
|
||||
"occurred_at": episode.occurred_at.isoformat(),
|
||||
"relevance": episode.importance_score,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"type": "episodic",
|
||||
"id": str(episode.id),
|
||||
"summary": episode.task_description,
|
||||
"outcome": episode.outcome.value
|
||||
if episode.outcome
|
||||
else None,
|
||||
"occurred_at": episode.occurred_at.isoformat(),
|
||||
"relevance": episode.importance_score,
|
||||
}
|
||||
)
|
||||
|
||||
elif memory_type == MemoryType.SEMANTIC:
|
||||
semantic = await self._get_semantic()
|
||||
@@ -448,15 +472,17 @@ class MemoryToolService:
|
||||
min_confidence=args.min_relevance,
|
||||
)
|
||||
for fact in facts:
|
||||
results.append({
|
||||
"type": "semantic",
|
||||
"id": str(fact.id),
|
||||
"subject": fact.subject,
|
||||
"predicate": fact.predicate,
|
||||
"object": fact.object,
|
||||
"confidence": fact.confidence,
|
||||
"relevance": fact.confidence,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"type": "semantic",
|
||||
"id": str(fact.id),
|
||||
"subject": fact.subject,
|
||||
"predicate": fact.predicate,
|
||||
"object": fact.object,
|
||||
"confidence": fact.confidence,
|
||||
"relevance": fact.confidence,
|
||||
}
|
||||
)
|
||||
|
||||
elif memory_type == MemoryType.PROCEDURAL:
|
||||
procedural = await self._get_procedural()
|
||||
@@ -467,15 +493,17 @@ class MemoryToolService:
|
||||
limit=args.limit,
|
||||
)
|
||||
for proc in procedures:
|
||||
results.append({
|
||||
"type": "procedural",
|
||||
"id": str(proc.id),
|
||||
"name": proc.name,
|
||||
"trigger": proc.trigger_pattern,
|
||||
"success_rate": proc.success_rate,
|
||||
"steps_count": len(proc.steps) if proc.steps else 0,
|
||||
"relevance": proc.success_rate,
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"type": "procedural",
|
||||
"id": str(proc.id),
|
||||
"name": proc.name,
|
||||
"trigger": proc.trigger_pattern,
|
||||
"success_rate": proc.success_rate,
|
||||
"steps_count": len(proc.steps) if proc.steps else 0,
|
||||
"relevance": proc.success_rate,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by relevance and limit
|
||||
results.sort(key=lambda x: x.get("relevance", 0), reverse=True)
|
||||
@@ -601,7 +629,11 @@ class MemoryToolService:
|
||||
if ep.task_type:
|
||||
task_types[ep.task_type] = task_types.get(ep.task_type, 0) + 1
|
||||
if ep.outcome:
|
||||
outcome_val = ep.outcome.value if hasattr(ep.outcome, "value") else str(ep.outcome)
|
||||
outcome_val = (
|
||||
ep.outcome.value
|
||||
if hasattr(ep.outcome, "value")
|
||||
else str(ep.outcome)
|
||||
)
|
||||
outcomes[outcome_val] = outcomes.get(outcome_val, 0) + 1
|
||||
|
||||
# Sort by frequency
|
||||
@@ -613,11 +645,13 @@ class MemoryToolService:
|
||||
examples = []
|
||||
if args.include_examples:
|
||||
for ep in episodes[: min(3, args.max_items)]:
|
||||
examples.append({
|
||||
"summary": ep.task_description,
|
||||
"task_type": ep.task_type,
|
||||
"outcome": ep.outcome.value if ep.outcome else None,
|
||||
})
|
||||
examples.append(
|
||||
{
|
||||
"summary": ep.task_description,
|
||||
"task_type": ep.task_type,
|
||||
"outcome": ep.outcome.value if ep.outcome else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"analysis_type": "recent_patterns",
|
||||
@@ -661,11 +695,13 @@ class MemoryToolService:
|
||||
examples = []
|
||||
if args.include_examples:
|
||||
for ep in successful[: min(3, args.max_items)]:
|
||||
examples.append({
|
||||
"summary": ep.task_description,
|
||||
"task_type": ep.task_type,
|
||||
"lessons": ep.lessons_learned,
|
||||
})
|
||||
examples.append(
|
||||
{
|
||||
"summary": ep.task_description,
|
||||
"task_type": ep.task_type,
|
||||
"lessons": ep.lessons_learned,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"analysis_type": "success_factors",
|
||||
@@ -694,9 +730,7 @@ class MemoryToolService:
|
||||
failure_by_task[task].append(ep)
|
||||
|
||||
# Most common failure types
|
||||
failure_counts = {
|
||||
task: len(eps) for task, eps in failure_by_task.items()
|
||||
}
|
||||
failure_counts = {task: len(eps) for task, eps in failure_by_task.items()}
|
||||
top_failures = sorted(failure_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
: args.max_items
|
||||
]
|
||||
@@ -704,12 +738,14 @@ class MemoryToolService:
|
||||
examples = []
|
||||
if args.include_examples:
|
||||
for ep in failed[: min(3, args.max_items)]:
|
||||
examples.append({
|
||||
"summary": ep.task_description,
|
||||
"task_type": ep.task_type,
|
||||
"lessons": ep.lessons_learned,
|
||||
"error": ep.outcome_details,
|
||||
})
|
||||
examples.append(
|
||||
{
|
||||
"summary": ep.task_description,
|
||||
"task_type": ep.task_type,
|
||||
"lessons": ep.lessons_learned,
|
||||
"error": ep.outcome_details,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"analysis_type": "failure_patterns",
|
||||
@@ -794,15 +830,21 @@ class MemoryToolService:
|
||||
insights = []
|
||||
|
||||
if top_tasks:
|
||||
insights.append(f"Most common task type: {top_tasks[0][0]} ({top_tasks[0][1]} occurrences)")
|
||||
insights.append(
|
||||
f"Most common task type: {top_tasks[0][0]} ({top_tasks[0][1]} occurrences)"
|
||||
)
|
||||
|
||||
total = sum(outcome_dist.values())
|
||||
if total > 0:
|
||||
success_rate = outcome_dist.get("success", 0) / total
|
||||
if success_rate > 0.8:
|
||||
insights.append("High success rate observed - current approach is working well")
|
||||
insights.append(
|
||||
"High success rate observed - current approach is working well"
|
||||
)
|
||||
elif success_rate < 0.5:
|
||||
insights.append("Success rate below 50% - consider reviewing procedures")
|
||||
insights.append(
|
||||
"Success rate below 50% - consider reviewing procedures"
|
||||
)
|
||||
|
||||
return insights
|
||||
|
||||
@@ -839,9 +881,13 @@ class MemoryToolService:
|
||||
|
||||
if top_failures:
|
||||
worst_task, count = top_failures[0]
|
||||
tips.append(f"'{worst_task}' has most failures ({count}) - needs procedure review")
|
||||
tips.append(
|
||||
f"'{worst_task}' has most failures ({count}) - needs procedure review"
|
||||
)
|
||||
|
||||
tips.append("Review lessons_learned from past failures before attempting similar tasks")
|
||||
tips.append(
|
||||
"Review lessons_learned from past failures before attempting similar tasks"
|
||||
)
|
||||
|
||||
return tips
|
||||
|
||||
@@ -912,7 +958,11 @@ class MemoryToolService:
|
||||
outcomes = {"success": 0, "failure": 0, "partial": 0, "abandoned": 0}
|
||||
for ep in recent_episodes:
|
||||
if ep.outcome:
|
||||
key = ep.outcome.value if hasattr(ep.outcome, "value") else str(ep.outcome)
|
||||
key = (
|
||||
ep.outcome.value
|
||||
if hasattr(ep.outcome, "value")
|
||||
else str(ep.outcome)
|
||||
)
|
||||
if key in outcomes:
|
||||
outcomes[key] += 1
|
||||
|
||||
@@ -942,7 +992,8 @@ class MemoryToolService:
|
||||
|
||||
# Filter by minimum success rate if specified
|
||||
procedures = [
|
||||
p for p in all_procedures
|
||||
p
|
||||
for p in all_procedures
|
||||
if args.min_success_rate is None or p.success_rate >= args.min_success_rate
|
||||
][: args.limit]
|
||||
|
||||
@@ -973,15 +1024,8 @@ class MemoryToolService:
|
||||
context: ToolContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the 'record_outcome' tool."""
|
||||
# Map outcome type to memory Outcome
|
||||
# Note: ABANDONED maps to FAILURE since core Outcome doesn't have ABANDONED
|
||||
outcome_map = {
|
||||
OutcomeType.SUCCESS: Outcome.SUCCESS,
|
||||
OutcomeType.PARTIAL: Outcome.PARTIAL,
|
||||
OutcomeType.FAILURE: Outcome.FAILURE,
|
||||
OutcomeType.ABANDONED: Outcome.FAILURE, # No ABANDONED in core enum
|
||||
}
|
||||
outcome = outcome_map.get(args.outcome, Outcome.FAILURE)
|
||||
# OutcomeType is now an alias for Outcome, use directly
|
||||
outcome = args.outcome
|
||||
|
||||
# Record in episodic memory
|
||||
episodic = await self._get_episodic()
|
||||
|
||||
@@ -12,6 +12,9 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# OutcomeType alias - uses core Outcome enum from types module for consistency
|
||||
from app.services.memory.types import Outcome as OutcomeType
|
||||
|
||||
|
||||
class MemoryType(str, Enum):
|
||||
"""Types of memory for storage operations."""
|
||||
@@ -32,15 +35,6 @@ class AnalysisType(str, Enum):
|
||||
LEARNING_PROGRESS = "learning_progress"
|
||||
|
||||
|
||||
class OutcomeType(str, Enum):
|
||||
"""Outcome types for record_outcome tool."""
|
||||
|
||||
SUCCESS = "success"
|
||||
PARTIAL = "partial"
|
||||
FAILURE = "failure"
|
||||
ABANDONED = "abandoned"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tool Argument Schemas (Pydantic models for validation)
|
||||
# ============================================================================
|
||||
|
||||
18
backend/app/services/memory/metrics/__init__.py
Normal file
18
backend/app/services/memory/metrics/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# app/services/memory/metrics/__init__.py
|
||||
"""Memory Metrics module."""
|
||||
|
||||
from .collector import (
|
||||
MemoryMetrics,
|
||||
get_memory_metrics,
|
||||
record_memory_operation,
|
||||
record_retrieval,
|
||||
reset_memory_metrics,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MemoryMetrics",
|
||||
"get_memory_metrics",
|
||||
"record_memory_operation",
|
||||
"record_retrieval",
|
||||
"reset_memory_metrics",
|
||||
]
|
||||
542
backend/app/services/memory/metrics/collector.py
Normal file
542
backend/app/services/memory/metrics/collector.py
Normal file
@@ -0,0 +1,542 @@
|
||||
# app/services/memory/metrics/collector.py
|
||||
"""
|
||||
Memory Metrics Collector
|
||||
|
||||
Collects and exposes metrics for the memory system.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import Counter, defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetricType(str, Enum):
|
||||
"""Types of metrics."""
|
||||
|
||||
COUNTER = "counter"
|
||||
GAUGE = "gauge"
|
||||
HISTOGRAM = "histogram"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricValue:
|
||||
"""A single metric value."""
|
||||
|
||||
name: str
|
||||
metric_type: MetricType
|
||||
value: float
|
||||
labels: dict[str, str] = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistogramBucket:
|
||||
"""Histogram bucket for distribution metrics."""
|
||||
|
||||
le: float # Less than or equal
|
||||
count: int = 0
|
||||
|
||||
|
||||
class MemoryMetrics:
|
||||
"""
|
||||
Collects memory system metrics.
|
||||
|
||||
Metrics tracked:
|
||||
- Memory operations (get/set/delete by type and scope)
|
||||
- Retrieval operations and latencies
|
||||
- Memory item counts by type
|
||||
- Consolidation operations and durations
|
||||
- Cache hit/miss rates
|
||||
- Procedure success rates
|
||||
- Embedding operations
|
||||
"""
|
||||
|
||||
# Maximum samples to keep in histogram (circular buffer)
|
||||
MAX_HISTOGRAM_SAMPLES = 10000
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize MemoryMetrics."""
|
||||
self._counters: dict[str, Counter[str]] = defaultdict(Counter)
|
||||
self._gauges: dict[str, dict[str, float]] = defaultdict(dict)
|
||||
# Use deque with maxlen for bounded memory (circular buffer)
|
||||
self._histograms: dict[str, deque[float]] = defaultdict(
|
||||
lambda: deque(maxlen=self.MAX_HISTOGRAM_SAMPLES)
|
||||
)
|
||||
self._histogram_buckets: dict[str, list[HistogramBucket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Initialize histogram buckets
|
||||
self._init_histogram_buckets()
|
||||
|
||||
def _init_histogram_buckets(self) -> None:
|
||||
"""Initialize histogram buckets for latency metrics."""
|
||||
# Fast operations (working memory)
|
||||
fast_buckets = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, float("inf")]
|
||||
|
||||
# Normal operations (retrieval)
|
||||
normal_buckets = [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, float("inf")]
|
||||
|
||||
# Slow operations (consolidation)
|
||||
slow_buckets = [0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, float("inf")]
|
||||
|
||||
self._histogram_buckets["memory_working_latency_seconds"] = [
|
||||
HistogramBucket(le=b) for b in fast_buckets
|
||||
]
|
||||
self._histogram_buckets["memory_retrieval_latency_seconds"] = [
|
||||
HistogramBucket(le=b) for b in normal_buckets
|
||||
]
|
||||
self._histogram_buckets["memory_consolidation_duration_seconds"] = [
|
||||
HistogramBucket(le=b) for b in slow_buckets
|
||||
]
|
||||
self._histogram_buckets["memory_embedding_latency_seconds"] = [
|
||||
HistogramBucket(le=b) for b in normal_buckets
|
||||
]
|
||||
|
||||
# Counter methods - Operations
|
||||
|
||||
async def inc_operations(
|
||||
self,
|
||||
operation: str,
|
||||
memory_type: str,
|
||||
scope: str | None = None,
|
||||
success: bool = True,
|
||||
) -> None:
|
||||
"""Increment memory operation counter."""
|
||||
async with self._lock:
|
||||
labels = f"operation={operation},memory_type={memory_type}"
|
||||
if scope:
|
||||
labels += f",scope={scope}"
|
||||
labels += f",success={str(success).lower()}"
|
||||
self._counters["memory_operations_total"][labels] += 1
|
||||
|
||||
async def inc_retrieval(
|
||||
self,
|
||||
memory_type: str,
|
||||
strategy: str,
|
||||
results_count: int,
|
||||
) -> None:
|
||||
"""Increment retrieval counter."""
|
||||
async with self._lock:
|
||||
labels = f"memory_type={memory_type},strategy={strategy}"
|
||||
self._counters["memory_retrievals_total"][labels] += 1
|
||||
|
||||
# Track result counts as a separate metric
|
||||
self._counters["memory_retrieval_results_total"][labels] += results_count
|
||||
|
||||
async def inc_cache_hit(self, cache_type: str) -> None:
|
||||
"""Increment cache hit counter."""
|
||||
async with self._lock:
|
||||
labels = f"cache_type={cache_type}"
|
||||
self._counters["memory_cache_hits_total"][labels] += 1
|
||||
|
||||
async def inc_cache_miss(self, cache_type: str) -> None:
|
||||
"""Increment cache miss counter."""
|
||||
async with self._lock:
|
||||
labels = f"cache_type={cache_type}"
|
||||
self._counters["memory_cache_misses_total"][labels] += 1
|
||||
|
||||
async def inc_consolidation(
|
||||
self,
|
||||
consolidation_type: str,
|
||||
success: bool = True,
|
||||
) -> None:
|
||||
"""Increment consolidation counter."""
|
||||
async with self._lock:
|
||||
labels = f"type={consolidation_type},success={str(success).lower()}"
|
||||
self._counters["memory_consolidations_total"][labels] += 1
|
||||
|
||||
async def inc_procedure_execution(
|
||||
self,
|
||||
procedure_id: str | None = None,
|
||||
success: bool = True,
|
||||
) -> None:
|
||||
"""Increment procedure execution counter."""
|
||||
async with self._lock:
|
||||
labels = f"success={str(success).lower()}"
|
||||
self._counters["memory_procedure_executions_total"][labels] += 1
|
||||
|
||||
async def inc_embeddings_generated(self, memory_type: str) -> None:
|
||||
"""Increment embeddings generated counter."""
|
||||
async with self._lock:
|
||||
labels = f"memory_type={memory_type}"
|
||||
self._counters["memory_embeddings_generated_total"][labels] += 1
|
||||
|
||||
async def inc_fact_reinforcements(self) -> None:
|
||||
"""Increment fact reinforcement counter."""
|
||||
async with self._lock:
|
||||
self._counters["memory_fact_reinforcements_total"][""] += 1
|
||||
|
||||
async def inc_episodes_recorded(self, outcome: str) -> None:
|
||||
"""Increment episodes recorded counter."""
|
||||
async with self._lock:
|
||||
labels = f"outcome={outcome}"
|
||||
self._counters["memory_episodes_recorded_total"][labels] += 1
|
||||
|
||||
async def inc_anomalies_detected(self, anomaly_type: str) -> None:
|
||||
"""Increment anomaly detection counter."""
|
||||
async with self._lock:
|
||||
labels = f"anomaly_type={anomaly_type}"
|
||||
self._counters["memory_anomalies_detected_total"][labels] += 1
|
||||
|
||||
async def inc_patterns_detected(self, pattern_type: str) -> None:
|
||||
"""Increment pattern detection counter."""
|
||||
async with self._lock:
|
||||
labels = f"pattern_type={pattern_type}"
|
||||
self._counters["memory_patterns_detected_total"][labels] += 1
|
||||
|
||||
async def inc_insights_generated(self, insight_type: str) -> None:
|
||||
"""Increment insight generation counter."""
|
||||
async with self._lock:
|
||||
labels = f"insight_type={insight_type}"
|
||||
self._counters["memory_insights_generated_total"][labels] += 1
|
||||
|
||||
# Gauge methods
|
||||
|
||||
async def set_memory_items_count(
|
||||
self,
|
||||
memory_type: str,
|
||||
scope: str,
|
||||
count: int,
|
||||
) -> None:
|
||||
"""Set memory item count gauge."""
|
||||
async with self._lock:
|
||||
labels = f"memory_type={memory_type},scope={scope}"
|
||||
self._gauges["memory_items_count"][labels] = float(count)
|
||||
|
||||
async def set_memory_size_bytes(
|
||||
self,
|
||||
memory_type: str,
|
||||
scope: str,
|
||||
size_bytes: int,
|
||||
) -> None:
|
||||
"""Set memory size gauge in bytes."""
|
||||
async with self._lock:
|
||||
labels = f"memory_type={memory_type},scope={scope}"
|
||||
self._gauges["memory_size_bytes"][labels] = float(size_bytes)
|
||||
|
||||
async def set_cache_size(self, cache_type: str, size: int) -> None:
|
||||
"""Set cache size gauge."""
|
||||
async with self._lock:
|
||||
labels = f"cache_type={cache_type}"
|
||||
self._gauges["memory_cache_size"][labels] = float(size)
|
||||
|
||||
async def set_procedure_success_rate(
|
||||
self,
|
||||
procedure_name: str,
|
||||
rate: float,
|
||||
) -> None:
|
||||
"""Set procedure success rate gauge (0-1)."""
|
||||
async with self._lock:
|
||||
labels = f"procedure_name={procedure_name}"
|
||||
self._gauges["memory_procedure_success_rate"][labels] = rate
|
||||
|
||||
async def set_active_sessions(self, count: int) -> None:
|
||||
"""Set active working memory sessions gauge."""
|
||||
async with self._lock:
|
||||
self._gauges["memory_active_sessions"][""] = float(count)
|
||||
|
||||
async def set_pending_consolidations(self, count: int) -> None:
|
||||
"""Set pending consolidations gauge."""
|
||||
async with self._lock:
|
||||
self._gauges["memory_pending_consolidations"][""] = float(count)
|
||||
|
||||
# Histogram methods
|
||||
|
||||
async def observe_working_latency(self, latency_seconds: float) -> None:
|
||||
"""Observe working memory operation latency."""
|
||||
async with self._lock:
|
||||
self._observe_histogram("memory_working_latency_seconds", latency_seconds)
|
||||
|
||||
async def observe_retrieval_latency(self, latency_seconds: float) -> None:
|
||||
"""Observe retrieval latency."""
|
||||
async with self._lock:
|
||||
self._observe_histogram("memory_retrieval_latency_seconds", latency_seconds)
|
||||
|
||||
async def observe_consolidation_duration(self, duration_seconds: float) -> None:
|
||||
"""Observe consolidation duration."""
|
||||
async with self._lock:
|
||||
self._observe_histogram(
|
||||
"memory_consolidation_duration_seconds", duration_seconds
|
||||
)
|
||||
|
||||
async def observe_embedding_latency(self, latency_seconds: float) -> None:
|
||||
"""Observe embedding generation latency."""
|
||||
async with self._lock:
|
||||
self._observe_histogram("memory_embedding_latency_seconds", latency_seconds)
|
||||
|
||||
def _observe_histogram(self, name: str, value: float) -> None:
|
||||
"""Record a value in a histogram."""
|
||||
self._histograms[name].append(value)
|
||||
|
||||
# Update buckets
|
||||
if name in self._histogram_buckets:
|
||||
for bucket in self._histogram_buckets[name]:
|
||||
if value <= bucket.le:
|
||||
bucket.count += 1
|
||||
|
||||
# Export methods
|
||||
|
||||
async def get_all_metrics(self) -> list[MetricValue]:
|
||||
"""Get all metrics as MetricValue objects."""
|
||||
metrics: list[MetricValue] = []
|
||||
|
||||
async with self._lock:
|
||||
# Export counters
|
||||
for name, counter in self._counters.items():
|
||||
for labels_str, value in counter.items():
|
||||
labels = self._parse_labels(labels_str)
|
||||
metrics.append(
|
||||
MetricValue(
|
||||
name=name,
|
||||
metric_type=MetricType.COUNTER,
|
||||
value=float(value),
|
||||
labels=labels,
|
||||
)
|
||||
)
|
||||
|
||||
# Export gauges
|
||||
for name, gauge_dict in self._gauges.items():
|
||||
for labels_str, gauge_value in gauge_dict.items():
|
||||
gauge_labels = self._parse_labels(labels_str)
|
||||
metrics.append(
|
||||
MetricValue(
|
||||
name=name,
|
||||
metric_type=MetricType.GAUGE,
|
||||
value=gauge_value,
|
||||
labels=gauge_labels,
|
||||
)
|
||||
)
|
||||
|
||||
# Export histogram summaries
|
||||
for name, values in self._histograms.items():
|
||||
if values:
|
||||
metrics.append(
|
||||
MetricValue(
|
||||
name=f"{name}_count",
|
||||
metric_type=MetricType.COUNTER,
|
||||
value=float(len(values)),
|
||||
)
|
||||
)
|
||||
metrics.append(
|
||||
MetricValue(
|
||||
name=f"{name}_sum",
|
||||
metric_type=MetricType.COUNTER,
|
||||
value=sum(values),
|
||||
)
|
||||
)
|
||||
|
||||
return metrics
|
||||
|
||||
async def get_prometheus_format(self) -> str:
|
||||
"""Export metrics in Prometheus text format."""
|
||||
lines: list[str] = []
|
||||
|
||||
async with self._lock:
|
||||
# Export counters
|
||||
for name, counter in self._counters.items():
|
||||
lines.append(f"# TYPE {name} counter")
|
||||
for labels_str, value in counter.items():
|
||||
if labels_str:
|
||||
lines.append(f"{name}{{{labels_str}}} {value}")
|
||||
else:
|
||||
lines.append(f"{name} {value}")
|
||||
|
||||
# Export gauges
|
||||
for name, gauge_dict in self._gauges.items():
|
||||
lines.append(f"# TYPE {name} gauge")
|
||||
for labels_str, gauge_value in gauge_dict.items():
|
||||
if labels_str:
|
||||
lines.append(f"{name}{{{labels_str}}} {gauge_value}")
|
||||
else:
|
||||
lines.append(f"{name} {gauge_value}")
|
||||
|
||||
# Export histograms
|
||||
for name, buckets in self._histogram_buckets.items():
|
||||
lines.append(f"# TYPE {name} histogram")
|
||||
for bucket in buckets:
|
||||
le_str = "+Inf" if bucket.le == float("inf") else str(bucket.le)
|
||||
lines.append(f'{name}_bucket{{le="{le_str}"}} {bucket.count}')
|
||||
|
||||
if name in self._histograms:
|
||||
values = self._histograms[name]
|
||||
lines.append(f"{name}_count {len(values)}")
|
||||
lines.append(f"{name}_sum {sum(values)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def get_summary(self) -> dict[str, Any]:
|
||||
"""Get a summary of key metrics."""
|
||||
async with self._lock:
|
||||
total_operations = sum(self._counters["memory_operations_total"].values())
|
||||
successful_operations = sum(
|
||||
v
|
||||
for k, v in self._counters["memory_operations_total"].items()
|
||||
if "success=true" in k
|
||||
)
|
||||
|
||||
total_retrievals = sum(self._counters["memory_retrievals_total"].values())
|
||||
|
||||
total_cache_hits = sum(self._counters["memory_cache_hits_total"].values())
|
||||
total_cache_misses = sum(
|
||||
self._counters["memory_cache_misses_total"].values()
|
||||
)
|
||||
cache_hit_rate = (
|
||||
total_cache_hits / (total_cache_hits + total_cache_misses)
|
||||
if (total_cache_hits + total_cache_misses) > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
total_consolidations = sum(
|
||||
self._counters["memory_consolidations_total"].values()
|
||||
)
|
||||
|
||||
total_episodes = sum(
|
||||
self._counters["memory_episodes_recorded_total"].values()
|
||||
)
|
||||
|
||||
# Calculate average latencies
|
||||
retrieval_latencies = list(
|
||||
self._histograms.get("memory_retrieval_latency_seconds", deque())
|
||||
)
|
||||
avg_retrieval_latency = (
|
||||
sum(retrieval_latencies) / len(retrieval_latencies)
|
||||
if retrieval_latencies
|
||||
else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_operations": total_operations,
|
||||
"successful_operations": successful_operations,
|
||||
"operation_success_rate": (
|
||||
successful_operations / total_operations
|
||||
if total_operations > 0
|
||||
else 1.0
|
||||
),
|
||||
"total_retrievals": total_retrievals,
|
||||
"cache_hit_rate": cache_hit_rate,
|
||||
"total_consolidations": total_consolidations,
|
||||
"total_episodes_recorded": total_episodes,
|
||||
"avg_retrieval_latency_ms": avg_retrieval_latency * 1000,
|
||||
"patterns_detected": sum(
|
||||
self._counters["memory_patterns_detected_total"].values()
|
||||
),
|
||||
"insights_generated": sum(
|
||||
self._counters["memory_insights_generated_total"].values()
|
||||
),
|
||||
"anomalies_detected": sum(
|
||||
self._counters["memory_anomalies_detected_total"].values()
|
||||
),
|
||||
"active_sessions": self._gauges.get("memory_active_sessions", {}).get(
|
||||
"", 0
|
||||
),
|
||||
"pending_consolidations": self._gauges.get(
|
||||
"memory_pending_consolidations", {}
|
||||
).get("", 0),
|
||||
}
|
||||
|
||||
async def get_cache_stats(self) -> dict[str, Any]:
|
||||
"""Get detailed cache statistics."""
|
||||
async with self._lock:
|
||||
stats: dict[str, Any] = {}
|
||||
|
||||
# Get hits/misses by cache type
|
||||
for labels_str, hits in self._counters["memory_cache_hits_total"].items():
|
||||
cache_type = self._parse_labels(labels_str).get("cache_type", "unknown")
|
||||
if cache_type not in stats:
|
||||
stats[cache_type] = {"hits": 0, "misses": 0}
|
||||
stats[cache_type]["hits"] = hits
|
||||
|
||||
for labels_str, misses in self._counters[
|
||||
"memory_cache_misses_total"
|
||||
].items():
|
||||
cache_type = self._parse_labels(labels_str).get("cache_type", "unknown")
|
||||
if cache_type not in stats:
|
||||
stats[cache_type] = {"hits": 0, "misses": 0}
|
||||
stats[cache_type]["misses"] = misses
|
||||
|
||||
# Calculate hit rates
|
||||
for data in stats.values():
|
||||
total = data["hits"] + data["misses"]
|
||||
data["hit_rate"] = data["hits"] / total if total > 0 else 0.0
|
||||
data["total"] = total
|
||||
|
||||
return stats
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset all metrics."""
|
||||
async with self._lock:
|
||||
self._counters.clear()
|
||||
self._gauges.clear()
|
||||
self._histograms.clear()
|
||||
self._init_histogram_buckets()
|
||||
|
||||
def _parse_labels(self, labels_str: str) -> dict[str, str]:
|
||||
"""Parse labels string into dictionary."""
|
||||
if not labels_str:
|
||||
return {}
|
||||
|
||||
labels = {}
|
||||
for pair in labels_str.split(","):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
labels[key.strip()] = value.strip()
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_metrics: MemoryMetrics | None = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_memory_metrics() -> MemoryMetrics:
|
||||
"""Get the singleton MemoryMetrics instance."""
|
||||
global _metrics
|
||||
|
||||
async with _lock:
|
||||
if _metrics is None:
|
||||
_metrics = MemoryMetrics()
|
||||
return _metrics
|
||||
|
||||
|
||||
async def reset_memory_metrics() -> None:
|
||||
"""Reset the singleton instance (for testing)."""
|
||||
global _metrics
|
||||
async with _lock:
|
||||
_metrics = None
|
||||
|
||||
|
||||
# Convenience functions
|
||||
|
||||
|
||||
async def record_memory_operation(
|
||||
operation: str,
|
||||
memory_type: str,
|
||||
scope: str | None = None,
|
||||
success: bool = True,
|
||||
latency_ms: float | None = None,
|
||||
) -> None:
|
||||
"""Record a memory operation."""
|
||||
metrics = await get_memory_metrics()
|
||||
await metrics.inc_operations(operation, memory_type, scope, success)
|
||||
|
||||
if latency_ms is not None and memory_type == "working":
|
||||
await metrics.observe_working_latency(latency_ms / 1000)
|
||||
|
||||
|
||||
async def record_retrieval(
|
||||
memory_type: str,
|
||||
strategy: str,
|
||||
results_count: int,
|
||||
latency_ms: float,
|
||||
) -> None:
|
||||
"""Record a retrieval operation."""
|
||||
metrics = await get_memory_metrics()
|
||||
await metrics.inc_retrieval(memory_type, strategy, results_count)
|
||||
await metrics.observe_retrieval_latency(latency_ms / 1000)
|
||||
@@ -22,6 +22,25 @@ from app.services.memory.types import Procedure, ProcedureCreate, RetrievalResul
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _escape_like_pattern(pattern: str) -> str:
|
||||
"""
|
||||
Escape SQL LIKE/ILIKE special characters to prevent pattern injection.
|
||||
|
||||
Characters escaped:
|
||||
- % (matches zero or more characters)
|
||||
- _ (matches exactly one character)
|
||||
- \\ (escape character itself)
|
||||
|
||||
Args:
|
||||
pattern: Raw search pattern from user input
|
||||
|
||||
Returns:
|
||||
Escaped pattern safe for use in LIKE/ILIKE queries
|
||||
"""
|
||||
# Escape backslash first, then the wildcards
|
||||
return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _model_to_procedure(model: ProcedureModel) -> Procedure:
|
||||
"""Convert SQLAlchemy model to Procedure dataclass."""
|
||||
return Procedure(
|
||||
@@ -320,7 +339,9 @@ class ProceduralMemory:
|
||||
if search_terms:
|
||||
conditions = []
|
||||
for term in search_terms:
|
||||
term_pattern = f"%{term}%"
|
||||
# Escape SQL wildcards to prevent pattern injection
|
||||
escaped_term = _escape_like_pattern(term)
|
||||
term_pattern = f"%{escaped_term}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
ProcedureModel.trigger_pattern.ilike(term_pattern),
|
||||
@@ -368,6 +389,10 @@ class ProceduralMemory:
|
||||
Returns:
|
||||
Best matching procedure or None
|
||||
"""
|
||||
# Escape SQL wildcards to prevent pattern injection
|
||||
escaped_task_type = _escape_like_pattern(task_type)
|
||||
task_type_pattern = f"%{escaped_task_type}%"
|
||||
|
||||
# Build query for procedures matching task type
|
||||
stmt = (
|
||||
select(ProcedureModel)
|
||||
@@ -376,8 +401,8 @@ class ProceduralMemory:
|
||||
(ProcedureModel.success_count + ProcedureModel.failure_count)
|
||||
>= min_uses,
|
||||
or_(
|
||||
ProcedureModel.trigger_pattern.ilike(f"%{task_type}%"),
|
||||
ProcedureModel.name.ilike(f"%{task_type}%"),
|
||||
ProcedureModel.trigger_pattern.ilike(task_type_pattern),
|
||||
ProcedureModel.name.ilike(task_type_pattern),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
38
backend/app/services/memory/reflection/__init__.py
Normal file
38
backend/app/services/memory/reflection/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# app/services/memory/reflection/__init__.py
|
||||
"""
|
||||
Memory Reflection Layer.
|
||||
|
||||
Analyzes patterns in agent experiences to generate actionable insights.
|
||||
"""
|
||||
|
||||
from .service import (
|
||||
MemoryReflection,
|
||||
ReflectionConfig,
|
||||
get_memory_reflection,
|
||||
)
|
||||
from .types import (
|
||||
Anomaly,
|
||||
AnomalyType,
|
||||
Factor,
|
||||
FactorType,
|
||||
Insight,
|
||||
InsightType,
|
||||
Pattern,
|
||||
PatternType,
|
||||
TimeRange,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Anomaly",
|
||||
"AnomalyType",
|
||||
"Factor",
|
||||
"FactorType",
|
||||
"Insight",
|
||||
"InsightType",
|
||||
"MemoryReflection",
|
||||
"Pattern",
|
||||
"PatternType",
|
||||
"ReflectionConfig",
|
||||
"TimeRange",
|
||||
"get_memory_reflection",
|
||||
]
|
||||
1451
backend/app/services/memory/reflection/service.py
Normal file
1451
backend/app/services/memory/reflection/service.py
Normal file
File diff suppressed because it is too large
Load Diff
304
backend/app/services/memory/reflection/types.py
Normal file
304
backend/app/services/memory/reflection/types.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# app/services/memory/reflection/types.py
|
||||
"""
|
||||
Memory Reflection Types.
|
||||
|
||||
Type definitions for pattern detection, anomaly detection, and insights.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
"""Get current UTC time as timezone-aware datetime."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class PatternType(str, Enum):
|
||||
"""Types of patterns detected in episodic memory."""
|
||||
|
||||
RECURRING_SUCCESS = "recurring_success"
|
||||
RECURRING_FAILURE = "recurring_failure"
|
||||
ACTION_SEQUENCE = "action_sequence"
|
||||
CONTEXT_CORRELATION = "context_correlation"
|
||||
TEMPORAL = "temporal"
|
||||
EFFICIENCY = "efficiency"
|
||||
|
||||
|
||||
class FactorType(str, Enum):
|
||||
"""Types of factors contributing to outcomes."""
|
||||
|
||||
ACTION = "action"
|
||||
CONTEXT = "context"
|
||||
TIMING = "timing"
|
||||
RESOURCE = "resource"
|
||||
PRECEDING_STATE = "preceding_state"
|
||||
|
||||
|
||||
class AnomalyType(str, Enum):
|
||||
"""Types of anomalies detected."""
|
||||
|
||||
UNUSUAL_DURATION = "unusual_duration"
|
||||
UNEXPECTED_OUTCOME = "unexpected_outcome"
|
||||
UNUSUAL_TOKEN_USAGE = "unusual_token_usage"
|
||||
UNUSUAL_FAILURE_RATE = "unusual_failure_rate"
|
||||
UNUSUAL_ACTION_PATTERN = "unusual_action_pattern"
|
||||
|
||||
|
||||
class InsightType(str, Enum):
|
||||
"""Types of insights generated."""
|
||||
|
||||
OPTIMIZATION = "optimization"
|
||||
WARNING = "warning"
|
||||
LEARNING = "learning"
|
||||
RECOMMENDATION = "recommendation"
|
||||
TREND = "trend"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeRange:
|
||||
"""Time range for reflection analysis."""
|
||||
|
||||
start: datetime
|
||||
end: datetime
|
||||
|
||||
@classmethod
|
||||
def last_hours(cls, hours: int = 24) -> "TimeRange":
|
||||
"""Create time range for last N hours."""
|
||||
end = _utcnow()
|
||||
start = datetime(
|
||||
end.year, end.month, end.day, end.hour, end.minute, end.second, tzinfo=UTC
|
||||
) - __import__("datetime").timedelta(hours=hours)
|
||||
return cls(start=start, end=end)
|
||||
|
||||
@classmethod
|
||||
def last_days(cls, days: int = 7) -> "TimeRange":
|
||||
"""Create time range for last N days."""
|
||||
from datetime import timedelta
|
||||
|
||||
end = _utcnow()
|
||||
start = end - timedelta(days=days)
|
||||
return cls(start=start, end=end)
|
||||
|
||||
@property
|
||||
def duration_hours(self) -> float:
|
||||
"""Get duration in hours."""
|
||||
return (self.end - self.start).total_seconds() / 3600
|
||||
|
||||
@property
|
||||
def duration_days(self) -> float:
|
||||
"""Get duration in days."""
|
||||
return (self.end - self.start).total_seconds() / 86400
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pattern:
|
||||
"""A detected pattern in episodic memory."""
|
||||
|
||||
id: UUID
|
||||
pattern_type: PatternType
|
||||
name: str
|
||||
description: str
|
||||
confidence: float
|
||||
occurrence_count: int
|
||||
episode_ids: list[UUID]
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def frequency(self) -> float:
|
||||
"""Calculate pattern frequency per day."""
|
||||
duration_days = (self.last_seen - self.first_seen).total_seconds() / 86400
|
||||
if duration_days < 1:
|
||||
duration_days = 1
|
||||
return self.occurrence_count / duration_days
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"pattern_type": self.pattern_type.value,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"confidence": self.confidence,
|
||||
"occurrence_count": self.occurrence_count,
|
||||
"episode_ids": [str(eid) for eid in self.episode_ids],
|
||||
"first_seen": self.first_seen.isoformat(),
|
||||
"last_seen": self.last_seen.isoformat(),
|
||||
"frequency": self.frequency,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Factor:
|
||||
"""A factor contributing to success or failure."""
|
||||
|
||||
id: UUID
|
||||
factor_type: FactorType
|
||||
name: str
|
||||
description: str
|
||||
impact_score: float
|
||||
correlation: float
|
||||
sample_size: int
|
||||
positive_examples: list[UUID]
|
||||
negative_examples: list[UUID]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def net_impact(self) -> float:
|
||||
"""Calculate net impact considering sample size."""
|
||||
# Weight impact by sample confidence
|
||||
confidence_weight = min(1.0, self.sample_size / 20)
|
||||
return self.impact_score * self.correlation * confidence_weight
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"factor_type": self.factor_type.value,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"impact_score": self.impact_score,
|
||||
"correlation": self.correlation,
|
||||
"sample_size": self.sample_size,
|
||||
"positive_examples": [str(eid) for eid in self.positive_examples],
|
||||
"negative_examples": [str(eid) for eid in self.negative_examples],
|
||||
"net_impact": self.net_impact,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Anomaly:
|
||||
"""An anomaly detected in memory patterns."""
|
||||
|
||||
id: UUID
|
||||
anomaly_type: AnomalyType
|
||||
description: str
|
||||
severity: float
|
||||
episode_ids: list[UUID]
|
||||
detected_at: datetime
|
||||
baseline_value: float
|
||||
observed_value: float
|
||||
deviation_factor: float
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_critical(self) -> bool:
|
||||
"""Check if anomaly is critical (severity > 0.8)."""
|
||||
return self.severity > 0.8
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"anomaly_type": self.anomaly_type.value,
|
||||
"description": self.description,
|
||||
"severity": self.severity,
|
||||
"episode_ids": [str(eid) for eid in self.episode_ids],
|
||||
"detected_at": self.detected_at.isoformat(),
|
||||
"baseline_value": self.baseline_value,
|
||||
"observed_value": self.observed_value,
|
||||
"deviation_factor": self.deviation_factor,
|
||||
"is_critical": self.is_critical,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Insight:
|
||||
"""An actionable insight generated from reflection."""
|
||||
|
||||
id: UUID
|
||||
insight_type: InsightType
|
||||
title: str
|
||||
description: str
|
||||
priority: float
|
||||
confidence: float
|
||||
source_patterns: list[UUID]
|
||||
source_factors: list[UUID]
|
||||
source_anomalies: list[UUID]
|
||||
recommended_actions: list[str]
|
||||
generated_at: datetime
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def actionable_score(self) -> float:
|
||||
"""Calculate how actionable this insight is."""
|
||||
action_weight = min(1.0, len(self.recommended_actions) / 3)
|
||||
return self.priority * self.confidence * action_weight
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"insight_type": self.insight_type.value,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"priority": self.priority,
|
||||
"confidence": self.confidence,
|
||||
"source_patterns": [str(pid) for pid in self.source_patterns],
|
||||
"source_factors": [str(fid) for fid in self.source_factors],
|
||||
"source_anomalies": [str(aid) for aid in self.source_anomalies],
|
||||
"recommended_actions": self.recommended_actions,
|
||||
"generated_at": self.generated_at.isoformat(),
|
||||
"actionable_score": self.actionable_score,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReflectionResult:
|
||||
"""Result of a reflection operation."""
|
||||
|
||||
patterns: list[Pattern]
|
||||
factors: list[Factor]
|
||||
anomalies: list[Anomaly]
|
||||
insights: list[Insight]
|
||||
time_range: TimeRange
|
||||
episodes_analyzed: int
|
||||
analysis_duration_seconds: float
|
||||
generated_at: datetime = field(default_factory=_utcnow)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"patterns": [p.to_dict() for p in self.patterns],
|
||||
"factors": [f.to_dict() for f in self.factors],
|
||||
"anomalies": [a.to_dict() for a in self.anomalies],
|
||||
"insights": [i.to_dict() for i in self.insights],
|
||||
"time_range": {
|
||||
"start": self.time_range.start.isoformat(),
|
||||
"end": self.time_range.end.isoformat(),
|
||||
"duration_hours": self.time_range.duration_hours,
|
||||
},
|
||||
"episodes_analyzed": self.episodes_analyzed,
|
||||
"analysis_duration_seconds": self.analysis_duration_seconds,
|
||||
"generated_at": self.generated_at.isoformat(),
|
||||
}
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""Generate a summary of the reflection results."""
|
||||
lines = [
|
||||
f"Reflection Analysis ({self.time_range.duration_days:.1f} days)",
|
||||
f"Episodes analyzed: {self.episodes_analyzed}",
|
||||
"",
|
||||
f"Patterns detected: {len(self.patterns)}",
|
||||
f"Success/failure factors: {len(self.factors)}",
|
||||
f"Anomalies found: {len(self.anomalies)}",
|
||||
f"Insights generated: {len(self.insights)}",
|
||||
]
|
||||
|
||||
if self.insights:
|
||||
lines.append("")
|
||||
lines.append("Top insights:")
|
||||
for insight in sorted(self.insights, key=lambda i: -i.priority)[:3]:
|
||||
lines.append(f" - [{insight.insight_type.value}] {insight.title}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -7,6 +7,7 @@ Global -> Project -> Agent Type -> Agent Instance -> Session
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar
|
||||
from uuid import UUID
|
||||
@@ -448,13 +449,24 @@ class ScopeManager:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton manager instance
|
||||
# Singleton manager instance with thread-safe initialization
|
||||
_manager: ScopeManager | None = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_scope_manager() -> ScopeManager:
|
||||
"""Get the singleton scope manager instance."""
|
||||
"""Get the singleton scope manager instance (thread-safe)."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = ScopeManager()
|
||||
with _manager_lock:
|
||||
# Double-check locking pattern
|
||||
if _manager is None:
|
||||
_manager = ScopeManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def reset_scope_manager() -> None:
|
||||
"""Reset the scope manager singleton (for testing)."""
|
||||
global _manager
|
||||
with _manager_lock:
|
||||
_manager = None
|
||||
|
||||
@@ -22,6 +22,25 @@ from app.services.memory.types import Episode, Fact, FactCreate, RetrievalResult
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _escape_like_pattern(pattern: str) -> str:
|
||||
"""
|
||||
Escape SQL LIKE/ILIKE special characters to prevent pattern injection.
|
||||
|
||||
Characters escaped:
|
||||
- % (matches zero or more characters)
|
||||
- _ (matches exactly one character)
|
||||
- \\ (escape character itself)
|
||||
|
||||
Args:
|
||||
pattern: Raw search pattern from user input
|
||||
|
||||
Returns:
|
||||
Escaped pattern safe for use in LIKE/ILIKE queries
|
||||
"""
|
||||
# Escape backslash first, then the wildcards
|
||||
return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _model_to_fact(model: FactModel) -> Fact:
|
||||
"""Convert SQLAlchemy model to Fact dataclass."""
|
||||
# SQLAlchemy Column types are inferred as Column[T] by mypy, but at runtime
|
||||
@@ -251,7 +270,9 @@ class SemanticMemory:
|
||||
if search_terms:
|
||||
conditions = []
|
||||
for term in search_terms[:5]: # Limit to 5 terms
|
||||
term_pattern = f"%{term}%"
|
||||
# Escape SQL wildcards to prevent pattern injection
|
||||
escaped_term = _escape_like_pattern(term)
|
||||
term_pattern = f"%{escaped_term}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
FactModel.subject.ilike(term_pattern),
|
||||
@@ -295,12 +316,16 @@ class SemanticMemory:
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Escape SQL wildcards to prevent pattern injection
|
||||
escaped_entity = _escape_like_pattern(entity)
|
||||
entity_pattern = f"%{escaped_entity}%"
|
||||
|
||||
stmt = (
|
||||
select(FactModel)
|
||||
.where(
|
||||
or_(
|
||||
FactModel.subject.ilike(f"%{entity}%"),
|
||||
FactModel.object.ilike(f"%{entity}%"),
|
||||
FactModel.subject.ilike(entity_pattern),
|
||||
FactModel.object.ilike(entity_pattern),
|
||||
)
|
||||
)
|
||||
.order_by(desc(FactModel.confidence), desc(FactModel.last_reinforced))
|
||||
|
||||
@@ -42,6 +42,7 @@ class Outcome(str, Enum):
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
PARTIAL = "partial"
|
||||
ABANDONED = "abandoned"
|
||||
|
||||
|
||||
class ConsolidationStatus(str, Enum):
|
||||
|
||||
@@ -423,7 +423,8 @@ class WorkingMemory:
|
||||
Returns:
|
||||
Checkpoint ID for later restoration
|
||||
"""
|
||||
checkpoint_id = str(uuid.uuid4())[:8]
|
||||
# Use full UUID to avoid collision risk (8 chars has ~50k collision at birthday paradox)
|
||||
checkpoint_id = str(uuid.uuid4())
|
||||
checkpoint_key = f"{_CHECKPOINT_PREFIX}{checkpoint_id}"
|
||||
|
||||
# Capture all current state
|
||||
|
||||
1118
backend/data/default_agent_types.json
Normal file
1118
backend/data/default_agent_types.json
Normal file
File diff suppressed because it is too large
Load Diff
879
backend/data/demo_data.json
Normal file
879
backend/data/demo_data.json
Normal file
@@ -0,0 +1,879 @@
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"name": "Acme Corp",
|
||||
"slug": "acme-corp",
|
||||
"description": "A leading provider of coyote-catching equipment."
|
||||
},
|
||||
{
|
||||
"name": "Globex Corporation",
|
||||
"slug": "globex",
|
||||
"description": "We own the East Coast."
|
||||
},
|
||||
{
|
||||
"name": "Soylent Corp",
|
||||
"slug": "soylent",
|
||||
"description": "Making food for the future."
|
||||
},
|
||||
{
|
||||
"name": "Initech",
|
||||
"slug": "initech",
|
||||
"description": "Software for the soul."
|
||||
},
|
||||
{
|
||||
"name": "Umbrella Corporation",
|
||||
"slug": "umbrella",
|
||||
"description": "Our business is life itself."
|
||||
},
|
||||
{
|
||||
"name": "Massive Dynamic",
|
||||
"slug": "massive-dynamic",
|
||||
"description": "What don't we do?"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"email": "demo@example.com",
|
||||
"password": "DemoPass1234!",
|
||||
"first_name": "Demo",
|
||||
"last_name": "User",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "alice@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Alice",
|
||||
"last_name": "Smith",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "bob@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Bob",
|
||||
"last_name": "Jones",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "charlie@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Charlie",
|
||||
"last_name": "Brown",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "diana@acme.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Diana",
|
||||
"last_name": "Prince",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "acme-corp",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "carol@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Carol",
|
||||
"last_name": "Williams",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "owner",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "dan@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Dan",
|
||||
"last_name": "Miller",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "ellen@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Ellen",
|
||||
"last_name": "Ripley",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "fred@globex.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Fred",
|
||||
"last_name": "Flintstone",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "globex",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "dave@soylent.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Dave",
|
||||
"last_name": "Brown",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "soylent",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "gina@soylent.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Gina",
|
||||
"last_name": "Torres",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "soylent",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "harry@soylent.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Harry",
|
||||
"last_name": "Potter",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "soylent",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "eve@initech.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Eve",
|
||||
"last_name": "Davis",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "initech",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "iris@initech.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Iris",
|
||||
"last_name": "West",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "initech",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "jack@initech.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Jack",
|
||||
"last_name": "Sparrow",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "initech",
|
||||
"role": "member",
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "frank@umbrella.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Frank",
|
||||
"last_name": "Miller",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "umbrella",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "george@umbrella.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "George",
|
||||
"last_name": "Costanza",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "umbrella",
|
||||
"role": "member",
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "kate@umbrella.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Kate",
|
||||
"last_name": "Bishop",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "umbrella",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "leo@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Leo",
|
||||
"last_name": "Messi",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "owner",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "mary@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Mary",
|
||||
"last_name": "Jane",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "nathan@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Nathan",
|
||||
"last_name": "Drake",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "olivia@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Olivia",
|
||||
"last_name": "Dunham",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "admin",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "peter@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Peter",
|
||||
"last_name": "Parker",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "quinn@massive.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Quinn",
|
||||
"last_name": "Mallory",
|
||||
"is_superuser": false,
|
||||
"organization_slug": "massive-dynamic",
|
||||
"role": "member",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "grace@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Grace",
|
||||
"last_name": "Hopper",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "heidi@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Heidi",
|
||||
"last_name": "Klum",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "ivan@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Ivan",
|
||||
"last_name": "Drago",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "rachel@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Rachel",
|
||||
"last_name": "Green",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "sam@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Sam",
|
||||
"last_name": "Wilson",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "tony@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Tony",
|
||||
"last_name": "Stark",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "una@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Una",
|
||||
"last_name": "Chin-Riley",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": false
|
||||
},
|
||||
{
|
||||
"email": "victor@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Victor",
|
||||
"last_name": "Von Doom",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"email": "wanda@example.com",
|
||||
"password": "Demo123!",
|
||||
"first_name": "Wanda",
|
||||
"last_name": "Maximoff",
|
||||
"is_superuser": false,
|
||||
"organization_slug": null,
|
||||
"role": null,
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "E-Commerce Platform Redesign",
|
||||
"slug": "ecommerce-redesign",
|
||||
"description": "Complete redesign of the e-commerce platform with modern UX, improved checkout flow, and mobile-first approach.",
|
||||
"owner_email": "__admin__",
|
||||
"autonomy_level": "milestone",
|
||||
"status": "active",
|
||||
"complexity": "complex",
|
||||
"client_mode": "technical",
|
||||
"settings": {
|
||||
"mcp_servers": ["gitea", "knowledge-base"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mobile Banking App",
|
||||
"slug": "mobile-banking",
|
||||
"description": "Secure mobile banking application with biometric authentication, transaction history, and real-time notifications.",
|
||||
"owner_email": "__admin__",
|
||||
"autonomy_level": "full_control",
|
||||
"status": "active",
|
||||
"complexity": "complex",
|
||||
"client_mode": "technical",
|
||||
"settings": {
|
||||
"mcp_servers": ["gitea", "knowledge-base"],
|
||||
"security_level": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Internal HR Portal",
|
||||
"slug": "hr-portal",
|
||||
"description": "Employee self-service portal for leave requests, performance reviews, and document management.",
|
||||
"owner_email": "__admin__",
|
||||
"autonomy_level": "autonomous",
|
||||
"status": "active",
|
||||
"complexity": "medium",
|
||||
"client_mode": "auto",
|
||||
"settings": {
|
||||
"mcp_servers": ["gitea", "knowledge-base"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "API Gateway Modernization",
|
||||
"slug": "api-gateway",
|
||||
"description": "Migrate legacy REST API gateway to modern GraphQL-based architecture with improved caching and rate limiting.",
|
||||
"owner_email": "__admin__",
|
||||
"autonomy_level": "milestone",
|
||||
"status": "active",
|
||||
"complexity": "complex",
|
||||
"client_mode": "technical",
|
||||
"settings": {
|
||||
"mcp_servers": ["gitea", "knowledge-base"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Customer Analytics Dashboard",
|
||||
"slug": "analytics-dashboard",
|
||||
"description": "Real-time analytics dashboard for customer behavior insights, cohort analysis, and predictive modeling.",
|
||||
"owner_email": "__admin__",
|
||||
"autonomy_level": "autonomous",
|
||||
"status": "completed",
|
||||
"complexity": "medium",
|
||||
"client_mode": "auto",
|
||||
"settings": {
|
||||
"mcp_servers": ["gitea", "knowledge-base"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DevOps Pipeline Automation",
|
||||
"slug": "devops-automation",
|
||||
"description": "Automate CI/CD pipelines with AI-assisted deployments, rollback detection, and infrastructure as code.",
|
||||
"owner_email": "__admin__",
|
||||
"autonomy_level": "full_control",
|
||||
"status": "active",
|
||||
"complexity": "complex",
|
||||
"client_mode": "technical",
|
||||
"settings": {
|
||||
"mcp_servers": ["gitea", "knowledge-base"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"sprints": [
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"name": "Sprint 1: Foundation",
|
||||
"number": 1,
|
||||
"goal": "Set up project infrastructure, design system, and core navigation components.",
|
||||
"start_date": "2026-01-06",
|
||||
"end_date": "2026-01-20",
|
||||
"status": "active",
|
||||
"planned_points": 21
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"name": "Sprint 2: Product Catalog",
|
||||
"number": 2,
|
||||
"goal": "Implement product listing, filtering, search, and detail pages.",
|
||||
"start_date": "2026-01-20",
|
||||
"end_date": "2026-02-03",
|
||||
"status": "planned",
|
||||
"planned_points": 34
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"name": "Sprint 1: Authentication",
|
||||
"number": 1,
|
||||
"goal": "Implement secure login, biometric authentication, and session management.",
|
||||
"start_date": "2026-01-06",
|
||||
"end_date": "2026-01-20",
|
||||
"status": "active",
|
||||
"planned_points": 26
|
||||
},
|
||||
{
|
||||
"project_slug": "hr-portal",
|
||||
"name": "Sprint 1: Core Features",
|
||||
"number": 1,
|
||||
"goal": "Build employee dashboard, leave request system, and basic document management.",
|
||||
"start_date": "2026-01-06",
|
||||
"end_date": "2026-01-20",
|
||||
"status": "active",
|
||||
"planned_points": 18
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"name": "Sprint 1: GraphQL Schema",
|
||||
"number": 1,
|
||||
"goal": "Define GraphQL schema and implement core resolvers for existing REST endpoints.",
|
||||
"start_date": "2025-12-23",
|
||||
"end_date": "2026-01-06",
|
||||
"status": "completed",
|
||||
"planned_points": 21
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"name": "Sprint 2: Caching Layer",
|
||||
"number": 2,
|
||||
"goal": "Implement Redis-based caching layer and query batching.",
|
||||
"start_date": "2026-01-06",
|
||||
"end_date": "2026-01-20",
|
||||
"status": "active",
|
||||
"planned_points": 26
|
||||
},
|
||||
{
|
||||
"project_slug": "analytics-dashboard",
|
||||
"name": "Sprint 1: Data Pipeline",
|
||||
"number": 1,
|
||||
"goal": "Set up data ingestion pipeline and real-time event processing.",
|
||||
"start_date": "2025-11-15",
|
||||
"end_date": "2025-11-29",
|
||||
"status": "completed",
|
||||
"planned_points": 18
|
||||
},
|
||||
{
|
||||
"project_slug": "analytics-dashboard",
|
||||
"name": "Sprint 2: Dashboard UI",
|
||||
"number": 2,
|
||||
"goal": "Build interactive dashboard with charts and filtering capabilities.",
|
||||
"start_date": "2025-11-29",
|
||||
"end_date": "2025-12-13",
|
||||
"status": "completed",
|
||||
"planned_points": 21
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"name": "Sprint 1: Pipeline Templates",
|
||||
"number": 1,
|
||||
"goal": "Create reusable CI/CD pipeline templates for common deployment patterns.",
|
||||
"start_date": "2026-01-06",
|
||||
"end_date": "2026-01-20",
|
||||
"status": "active",
|
||||
"planned_points": 24
|
||||
}
|
||||
],
|
||||
"agent_instances": [
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"agent_type_slug": "product-owner",
|
||||
"name": "Aria",
|
||||
"status": "idle"
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"agent_type_slug": "solutions-architect",
|
||||
"name": "Marcus",
|
||||
"status": "idle"
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"agent_type_slug": "senior-engineer",
|
||||
"name": "Zara",
|
||||
"status": "working",
|
||||
"current_task": "Implementing responsive navigation component"
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"agent_type_slug": "product-owner",
|
||||
"name": "Felix",
|
||||
"status": "waiting",
|
||||
"current_task": "Awaiting security requirements clarification"
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"agent_type_slug": "senior-engineer",
|
||||
"name": "Luna",
|
||||
"status": "working",
|
||||
"current_task": "Implementing biometric authentication flow"
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"agent_type_slug": "qa-engineer",
|
||||
"name": "Rex",
|
||||
"status": "idle"
|
||||
},
|
||||
{
|
||||
"project_slug": "hr-portal",
|
||||
"agent_type_slug": "business-analyst",
|
||||
"name": "Nova",
|
||||
"status": "working",
|
||||
"current_task": "Documenting leave request workflow"
|
||||
},
|
||||
{
|
||||
"project_slug": "hr-portal",
|
||||
"agent_type_slug": "senior-engineer",
|
||||
"name": "Atlas",
|
||||
"status": "working",
|
||||
"current_task": "Building employee dashboard API"
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"agent_type_slug": "solutions-architect",
|
||||
"name": "Orion",
|
||||
"status": "working",
|
||||
"current_task": "Designing caching strategy for GraphQL queries"
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"agent_type_slug": "senior-engineer",
|
||||
"name": "Cleo",
|
||||
"status": "working",
|
||||
"current_task": "Implementing Redis cache invalidation"
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"agent_type_slug": "devops-engineer",
|
||||
"name": "Volt",
|
||||
"status": "working",
|
||||
"current_task": "Creating Terraform modules for AWS ECS"
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"agent_type_slug": "senior-engineer",
|
||||
"name": "Sage",
|
||||
"status": "idle"
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"agent_type_slug": "qa-engineer",
|
||||
"name": "Echo",
|
||||
"status": "waiting",
|
||||
"current_task": "Waiting for pipeline templates to test"
|
||||
}
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"sprint_number": 1,
|
||||
"type": "story",
|
||||
"title": "Design responsive navigation component",
|
||||
"body": "As a user, I want a navigation menu that works seamlessly on both desktop and mobile devices.\n\n## Acceptance Criteria\n- Hamburger menu on mobile viewports\n- Sticky header on scroll\n- Keyboard accessible\n- Screen reader compatible",
|
||||
"status": "in_progress",
|
||||
"priority": "high",
|
||||
"labels": ["frontend", "design-system"],
|
||||
"story_points": 5,
|
||||
"assigned_agent_name": "Zara"
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Set up Tailwind CSS configuration",
|
||||
"body": "Configure Tailwind CSS with custom design tokens for the e-commerce platform.\n\n- Define color palette\n- Set up typography scale\n- Configure breakpoints\n- Add custom utilities",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"labels": ["frontend", "infrastructure"],
|
||||
"story_points": 3
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Create base component library structure",
|
||||
"body": "Set up the foundational component library with:\n- Button variants\n- Form inputs\n- Card component\n- Modal system",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"labels": ["frontend", "design-system"],
|
||||
"story_points": 8
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"sprint_number": 1,
|
||||
"type": "story",
|
||||
"title": "Implement user authentication flow",
|
||||
"body": "As a user, I want to sign up, log in, and manage my account.\n\n## Features\n- Email/password registration\n- Social login (Google, GitHub)\n- Password reset flow\n- Email verification",
|
||||
"status": "open",
|
||||
"priority": "critical",
|
||||
"labels": ["auth", "backend", "frontend"],
|
||||
"story_points": 13
|
||||
},
|
||||
{
|
||||
"project_slug": "ecommerce-redesign",
|
||||
"sprint_number": 2,
|
||||
"type": "epic",
|
||||
"title": "Product Catalog System",
|
||||
"body": "Complete product catalog implementation including:\n- Product listing with pagination\n- Advanced filtering and search\n- Product detail pages\n- Category navigation",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["catalog", "backend", "frontend"],
|
||||
"story_points": null
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"sprint_number": 1,
|
||||
"type": "story",
|
||||
"title": "Implement biometric authentication",
|
||||
"body": "As a user, I want to log in using Face ID or Touch ID for quick and secure access.\n\n## Requirements\n- Support Face ID on iOS\n- Support fingerprint on Android\n- Fallback to PIN/password\n- Secure keychain storage",
|
||||
"status": "in_progress",
|
||||
"priority": "critical",
|
||||
"labels": ["auth", "security", "mobile"],
|
||||
"story_points": 8,
|
||||
"assigned_agent_name": "Luna"
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Set up secure session management",
|
||||
"body": "Implement secure session handling with:\n- JWT tokens with short expiry\n- Refresh token rotation\n- Session timeout handling\n- Multi-device session management",
|
||||
"status": "open",
|
||||
"priority": "critical",
|
||||
"labels": ["auth", "security", "backend"],
|
||||
"story_points": 5
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"sprint_number": 1,
|
||||
"type": "bug",
|
||||
"title": "Fix token refresh race condition",
|
||||
"body": "When multiple API calls happen simultaneously after token expiry, multiple refresh requests are made causing 401 errors.\n\n## Steps to Reproduce\n1. Wait for token to expire\n2. Trigger multiple API calls at once\n3. Observe multiple 401 errors",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["bug", "auth", "backend"],
|
||||
"story_points": 3
|
||||
},
|
||||
{
|
||||
"project_slug": "mobile-banking",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Implement PIN entry screen",
|
||||
"body": "Create secure PIN entry component with:\n- Masked input display\n- Haptic feedback\n- Brute force protection (lockout after 5 attempts)\n- Secure PIN storage",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["auth", "mobile", "frontend"],
|
||||
"story_points": 5
|
||||
},
|
||||
{
|
||||
"project_slug": "hr-portal",
|
||||
"sprint_number": 1,
|
||||
"type": "story",
|
||||
"title": "Build employee dashboard",
|
||||
"body": "As an employee, I want a dashboard showing my key information at a glance.\n\n## Dashboard Widgets\n- Leave balance\n- Pending approvals\n- Upcoming holidays\n- Recent announcements",
|
||||
"status": "in_progress",
|
||||
"priority": "high",
|
||||
"labels": ["frontend", "dashboard"],
|
||||
"story_points": 5,
|
||||
"assigned_agent_name": "Atlas"
|
||||
},
|
||||
{
|
||||
"project_slug": "hr-portal",
|
||||
"sprint_number": 1,
|
||||
"type": "story",
|
||||
"title": "Implement leave request system",
|
||||
"body": "As an employee, I want to submit and track leave requests.\n\n## Features\n- Submit leave request with date range\n- View leave balance by type\n- Track request status\n- Manager approval workflow",
|
||||
"status": "in_progress",
|
||||
"priority": "high",
|
||||
"labels": ["backend", "frontend", "workflow"],
|
||||
"story_points": 8,
|
||||
"assigned_agent_name": "Nova"
|
||||
},
|
||||
{
|
||||
"project_slug": "hr-portal",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Set up document storage integration",
|
||||
"body": "Integrate with S3-compatible storage for employee documents:\n- Secure upload/download\n- File type validation\n- Size limits\n- Virus scanning",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"labels": ["backend", "infrastructure", "storage"],
|
||||
"story_points": 5
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"sprint_number": 2,
|
||||
"type": "story",
|
||||
"title": "Implement Redis caching layer",
|
||||
"body": "As an API consumer, I want responses to be cached for improved performance.\n\n## Requirements\n- Cache GraphQL query results\n- Configurable TTL per query type\n- Cache invalidation on mutations\n- Cache hit/miss metrics",
|
||||
"status": "in_progress",
|
||||
"priority": "critical",
|
||||
"labels": ["backend", "performance", "redis"],
|
||||
"story_points": 8,
|
||||
"assigned_agent_name": "Cleo"
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"sprint_number": 2,
|
||||
"type": "task",
|
||||
"title": "Set up query batching and deduplication",
|
||||
"body": "Implement DataLoader pattern for:\n- Batching multiple queries into single database calls\n- Deduplicating identical queries within request scope\n- N+1 query prevention",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["backend", "performance", "graphql"],
|
||||
"story_points": 5
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"sprint_number": 2,
|
||||
"type": "task",
|
||||
"title": "Implement rate limiting middleware",
|
||||
"body": "Add rate limiting to prevent API abuse:\n- Per-user rate limits\n- Per-IP fallback for anonymous requests\n- Sliding window algorithm\n- Custom limits per operation type",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["backend", "security", "middleware"],
|
||||
"story_points": 5,
|
||||
"assigned_agent_name": "Orion"
|
||||
},
|
||||
{
|
||||
"project_slug": "api-gateway",
|
||||
"sprint_number": 2,
|
||||
"type": "bug",
|
||||
"title": "Fix N+1 query in user resolver",
|
||||
"body": "The user resolver is making separate database calls for each user's organization.\n\n## Steps to Reproduce\n1. Query users with organization field\n2. Check database logs\n3. Observe N+1 queries",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["bug", "performance", "graphql"],
|
||||
"story_points": 3
|
||||
},
|
||||
{
|
||||
"project_slug": "analytics-dashboard",
|
||||
"sprint_number": 2,
|
||||
"type": "story",
|
||||
"title": "Build cohort analysis charts",
|
||||
"body": "As a product manager, I want to analyze user cohorts over time.\n\n## Features\n- Weekly/monthly cohort grouping\n- Retention curve visualization\n- Cohort comparison view",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"labels": ["frontend", "charts", "analytics"],
|
||||
"story_points": 8
|
||||
},
|
||||
{
|
||||
"project_slug": "analytics-dashboard",
|
||||
"sprint_number": 2,
|
||||
"type": "task",
|
||||
"title": "Implement real-time event streaming",
|
||||
"body": "Set up WebSocket connection for live event updates:\n- Event type filtering\n- Buffering for high-volume periods\n- Reconnection handling",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"labels": ["backend", "websocket", "realtime"],
|
||||
"story_points": 5
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"sprint_number": 1,
|
||||
"type": "epic",
|
||||
"title": "CI/CD Pipeline Templates",
|
||||
"body": "Create reusable pipeline templates for common deployment patterns.\n\n## Templates Needed\n- Node.js applications\n- Python applications\n- Docker-based deployments\n- Kubernetes deployments",
|
||||
"status": "in_progress",
|
||||
"priority": "critical",
|
||||
"labels": ["infrastructure", "cicd", "templates"],
|
||||
"story_points": null
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"sprint_number": 1,
|
||||
"type": "story",
|
||||
"title": "Create Terraform modules for AWS ECS",
|
||||
"body": "As a DevOps engineer, I want Terraform modules for ECS deployments.\n\n## Modules\n- ECS cluster configuration\n- Service and task definitions\n- Load balancer integration\n- Auto-scaling policies",
|
||||
"status": "in_progress",
|
||||
"priority": "high",
|
||||
"labels": ["terraform", "aws", "ecs"],
|
||||
"story_points": 8,
|
||||
"assigned_agent_name": "Volt"
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Set up Gitea Actions runners",
|
||||
"body": "Configure self-hosted Gitea Actions runners:\n- Docker-in-Docker support\n- Caching for npm/pip\n- Secrets management\n- Resource limits",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"labels": ["infrastructure", "gitea", "cicd"],
|
||||
"story_points": 5
|
||||
},
|
||||
{
|
||||
"project_slug": "devops-automation",
|
||||
"sprint_number": 1,
|
||||
"type": "task",
|
||||
"title": "Implement rollback detection system",
|
||||
"body": "AI-assisted rollback detection:\n- Monitor deployment health metrics\n- Automatic rollback triggers\n- Notification system\n- Post-rollback analysis",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"labels": ["ai", "monitoring", "automation"],
|
||||
"story_points": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
507
backend/docs/MEMORY_SYSTEM.md
Normal file
507
backend/docs/MEMORY_SYSTEM.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# Agent Memory System
|
||||
|
||||
Comprehensive multi-tier cognitive memory for AI agents, enabling state persistence, experiential learning, and context continuity across sessions.
|
||||
|
||||
## Overview
|
||||
|
||||
The Agent Memory System implements a cognitive architecture inspired by human memory:
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| Agent Memory System |
|
||||
+------------------------------------------------------------------+
|
||||
| |
|
||||
| +------------------+ +------------------+ |
|
||||
| | Working Memory |----consolidate---->| Episodic Memory | |
|
||||
| | (Redis/In-Mem) | | (PostgreSQL) | |
|
||||
| | | | | |
|
||||
| | - Current task | | - Past sessions | |
|
||||
| | - Variables | | - Experiences | |
|
||||
| | - Scratchpad | | - Outcomes | |
|
||||
| +------------------+ +--------+---------+ |
|
||||
| | |
|
||||
| extract | |
|
||||
| v |
|
||||
| +------------------+ +------------------+ |
|
||||
| |Procedural Memory |<-----learn from----| Semantic Memory | |
|
||||
| | (PostgreSQL) | | (PostgreSQL + | |
|
||||
| | | | pgvector) | |
|
||||
| | - Procedures | | | |
|
||||
| | - Skills | | - Facts | |
|
||||
| | - Patterns | | - Entities | |
|
||||
| +------------------+ | - Relationships | |
|
||||
| +------------------+ |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Memory Types
|
||||
|
||||
### Working Memory
|
||||
Short-term, session-scoped memory for current task state.
|
||||
|
||||
**Features:**
|
||||
- Key-value storage with TTL
|
||||
- Task state tracking
|
||||
- Scratchpad for reasoning
|
||||
- Checkpoint/restore support
|
||||
- Redis primary with in-memory fallback
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from app.services.memory.working import WorkingMemory
|
||||
|
||||
memory = WorkingMemory(scope_context)
|
||||
await memory.set("key", {"data": "value"}, ttl_seconds=3600)
|
||||
value = await memory.get("key")
|
||||
|
||||
# Task state
|
||||
await memory.set_task_state(TaskState(task_id="t1", status="running"))
|
||||
state = await memory.get_task_state()
|
||||
|
||||
# Checkpoints
|
||||
checkpoint_id = await memory.create_checkpoint()
|
||||
await memory.restore_checkpoint(checkpoint_id)
|
||||
```
|
||||
|
||||
### Episodic Memory
|
||||
Experiential records of past agent actions and outcomes.
|
||||
|
||||
**Features:**
|
||||
- Records task completions and failures
|
||||
- Semantic similarity search (pgvector)
|
||||
- Temporal and outcome-based retrieval
|
||||
- Importance scoring
|
||||
- Episode summarization
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from app.services.memory.episodic import EpisodicMemory
|
||||
|
||||
memory = EpisodicMemory(session, embedder)
|
||||
|
||||
# Record an episode
|
||||
episode = await memory.record_episode(
|
||||
project_id=project_id,
|
||||
episode=EpisodeCreate(
|
||||
task_type="code_review",
|
||||
task_description="Review PR #42",
|
||||
outcome=Outcome.SUCCESS,
|
||||
actions=[{"type": "analyze", "target": "src/"}],
|
||||
)
|
||||
)
|
||||
|
||||
# Search similar experiences
|
||||
similar = await memory.search_similar(
|
||||
project_id=project_id,
|
||||
query="debugging memory leak",
|
||||
limit=5
|
||||
)
|
||||
|
||||
# Get recent episodes
|
||||
recent = await memory.get_recent(project_id, limit=10)
|
||||
```
|
||||
|
||||
### Semantic Memory
|
||||
Learned facts and knowledge with confidence scoring.
|
||||
|
||||
**Features:**
|
||||
- Triple format (subject, predicate, object)
|
||||
- Confidence scoring with decay
|
||||
- Fact extraction from episodes
|
||||
- Conflict resolution
|
||||
- Entity-based retrieval
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from app.services.memory.semantic import SemanticMemory
|
||||
|
||||
memory = SemanticMemory(session, embedder)
|
||||
|
||||
# Store a fact
|
||||
fact = await memory.store_fact(
|
||||
project_id=project_id,
|
||||
fact=FactCreate(
|
||||
subject="UserService",
|
||||
predicate="handles",
|
||||
object="authentication",
|
||||
confidence=0.9,
|
||||
)
|
||||
)
|
||||
|
||||
# Search facts
|
||||
facts = await memory.search_facts(project_id, "authentication flow")
|
||||
|
||||
# Reinforce on repeated learning
|
||||
await memory.reinforce_fact(fact.id)
|
||||
```
|
||||
|
||||
### Procedural Memory
|
||||
Learned skills and procedures from successful patterns.
|
||||
|
||||
**Features:**
|
||||
- Procedure recording from task patterns
|
||||
- Trigger-based matching
|
||||
- Success rate tracking
|
||||
- Procedure suggestions
|
||||
- Step-by-step storage
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from app.services.memory.procedural import ProceduralMemory
|
||||
|
||||
memory = ProceduralMemory(session, embedder)
|
||||
|
||||
# Record a procedure
|
||||
procedure = await memory.record_procedure(
|
||||
project_id=project_id,
|
||||
procedure=ProcedureCreate(
|
||||
name="PR Review Process",
|
||||
trigger_pattern="code review requested",
|
||||
steps=[
|
||||
Step(action="fetch_diff"),
|
||||
Step(action="analyze_changes"),
|
||||
Step(action="check_tests"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Find matching procedures
|
||||
matches = await memory.find_matching(project_id, "need to review code")
|
||||
|
||||
# Record outcomes
|
||||
await memory.record_outcome(procedure.id, success=True)
|
||||
```
|
||||
|
||||
## Memory Scoping
|
||||
|
||||
Memory is organized in a hierarchical scope structure:
|
||||
|
||||
```
|
||||
Global Memory (shared by all)
|
||||
└── Project Memory (per project)
|
||||
└── Agent Type Memory (per agent type)
|
||||
└── Agent Instance Memory (per instance)
|
||||
└── Session Memory (ephemeral)
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
from app.services.memory.scoping import ScopeManager, ScopeLevel
|
||||
|
||||
manager = ScopeManager(session)
|
||||
|
||||
# Get scoped memories with inheritance
|
||||
memories = await manager.get_scoped_memories(
|
||||
context=ScopeContext(
|
||||
project_id=project_id,
|
||||
agent_type_id=agent_type_id,
|
||||
agent_instance_id=agent_instance_id,
|
||||
session_id=session_id,
|
||||
),
|
||||
include_inherited=True, # Include parent scopes
|
||||
)
|
||||
```
|
||||
|
||||
## Memory Consolidation
|
||||
|
||||
Automatic background processes transfer and extract knowledge:
|
||||
|
||||
```
|
||||
Working Memory ──> Episodic Memory ──> Semantic Memory
|
||||
└──> Procedural Memory
|
||||
```
|
||||
|
||||
**Consolidation Types:**
|
||||
- `working_to_episodic`: Transfer session state to episodes (on session end)
|
||||
- `episodic_to_semantic`: Extract facts from experiences
|
||||
- `episodic_to_procedural`: Learn procedures from patterns
|
||||
- `prune`: Remove low-value memories
|
||||
|
||||
**Celery Tasks:**
|
||||
```python
|
||||
from app.tasks.memory_consolidation import (
|
||||
consolidate_session,
|
||||
run_nightly_consolidation,
|
||||
prune_old_memories,
|
||||
)
|
||||
|
||||
# Manual consolidation
|
||||
consolidate_session.delay(session_id)
|
||||
|
||||
# Scheduled nightly (3 AM by default)
|
||||
run_nightly_consolidation.delay()
|
||||
```
|
||||
|
||||
## Memory Retrieval
|
||||
|
||||
### Hybrid Retrieval
|
||||
Combine multiple retrieval strategies:
|
||||
|
||||
```python
|
||||
from app.services.memory.indexing import RetrievalEngine
|
||||
|
||||
engine = RetrievalEngine(session, embedder)
|
||||
|
||||
# Hybrid search across memory types
|
||||
results = await engine.retrieve_hybrid(
|
||||
project_id=project_id,
|
||||
query="authentication error handling",
|
||||
memory_types=["episodic", "semantic", "procedural"],
|
||||
filters={"outcome": "success"},
|
||||
limit=10,
|
||||
)
|
||||
```
|
||||
|
||||
### Index Types
|
||||
- **Vector Index**: Semantic similarity (HNSW/pgvector)
|
||||
- **Temporal Index**: Time-based retrieval
|
||||
- **Entity Index**: Entity mention lookup
|
||||
- **Outcome Index**: Success/failure filtering
|
||||
|
||||
## MCP Tools
|
||||
|
||||
The memory system exposes MCP tools for agent use:
|
||||
|
||||
### `remember`
|
||||
Store information in memory.
|
||||
```json
|
||||
{
|
||||
"memory_type": "working",
|
||||
"content": {"key": "value"},
|
||||
"importance": 0.8,
|
||||
"ttl_seconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
### `recall`
|
||||
Retrieve from memory.
|
||||
```json
|
||||
{
|
||||
"query": "authentication patterns",
|
||||
"memory_types": ["episodic", "semantic"],
|
||||
"limit": 10,
|
||||
"filters": {"outcome": "success"}
|
||||
}
|
||||
```
|
||||
|
||||
### `forget`
|
||||
Remove from memory.
|
||||
```json
|
||||
{
|
||||
"memory_type": "working",
|
||||
"key": "temp_data"
|
||||
}
|
||||
```
|
||||
|
||||
### `reflect`
|
||||
Analyze memory patterns.
|
||||
```json
|
||||
{
|
||||
"analysis_type": "success_factors",
|
||||
"task_type": "code_review",
|
||||
"time_range_days": 30
|
||||
}
|
||||
```
|
||||
|
||||
### `get_memory_stats`
|
||||
Get memory usage statistics.
|
||||
|
||||
### `record_outcome`
|
||||
Record task success/failure for learning.
|
||||
|
||||
## Memory Reflection
|
||||
|
||||
Analyze patterns and generate insights from memory:
|
||||
|
||||
```python
|
||||
from app.services.memory.reflection import MemoryReflection, TimeRange
|
||||
|
||||
reflection = MemoryReflection(session)
|
||||
|
||||
# Detect patterns
|
||||
patterns = await reflection.analyze_patterns(
|
||||
project_id=project_id,
|
||||
time_range=TimeRange.last_days(30),
|
||||
)
|
||||
|
||||
# Identify success factors
|
||||
factors = await reflection.identify_success_factors(
|
||||
project_id=project_id,
|
||||
task_type="code_review",
|
||||
)
|
||||
|
||||
# Detect anomalies
|
||||
anomalies = await reflection.detect_anomalies(
|
||||
project_id=project_id,
|
||||
baseline_days=30,
|
||||
)
|
||||
|
||||
# Generate insights
|
||||
insights = await reflection.generate_insights(project_id)
|
||||
|
||||
# Comprehensive reflection
|
||||
result = await reflection.reflect(project_id)
|
||||
print(result.summary)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings use the `MEM_` environment variable prefix:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MEM_WORKING_MEMORY_BACKEND` | `redis` | Backend: `redis` or `memory` |
|
||||
| `MEM_WORKING_MEMORY_DEFAULT_TTL_SECONDS` | `3600` | Default TTL (1 hour) |
|
||||
| `MEM_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL |
|
||||
| `MEM_EPISODIC_MAX_EPISODES_PER_PROJECT` | `10000` | Max episodes per project |
|
||||
| `MEM_EPISODIC_RETENTION_DAYS` | `365` | Episode retention period |
|
||||
| `MEM_SEMANTIC_MAX_FACTS_PER_PROJECT` | `50000` | Max facts per project |
|
||||
| `MEM_SEMANTIC_CONFIDENCE_DECAY_DAYS` | `90` | Confidence half-life |
|
||||
| `MEM_EMBEDDING_MODEL` | `text-embedding-3-small` | Embedding model |
|
||||
| `MEM_EMBEDDING_DIMENSIONS` | `1536` | Vector dimensions |
|
||||
| `MEM_RETRIEVAL_MIN_SIMILARITY` | `0.5` | Minimum similarity score |
|
||||
| `MEM_CONSOLIDATION_ENABLED` | `true` | Enable auto-consolidation |
|
||||
| `MEM_CONSOLIDATION_SCHEDULE_CRON` | `0 3 * * *` | Nightly schedule |
|
||||
| `MEM_CACHE_ENABLED` | `true` | Enable retrieval caching |
|
||||
| `MEM_CACHE_TTL_SECONDS` | `300` | Cache TTL (5 minutes) |
|
||||
|
||||
See `app/services/memory/config.py` for complete configuration options.
|
||||
|
||||
## Integration with Context Engine
|
||||
|
||||
Memory integrates with the Context Engine as a context source:
|
||||
|
||||
```python
|
||||
from app.services.memory.integration import MemoryContextSource
|
||||
|
||||
# Register as context source
|
||||
source = MemoryContextSource(memory_manager)
|
||||
context_engine.register_source(source)
|
||||
|
||||
# Memory is automatically included in context assembly
|
||||
context = await context_engine.assemble_context(
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
current_task="Review authentication code",
|
||||
)
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
Multi-layer caching for performance:
|
||||
|
||||
- **Hot Cache**: Frequently accessed memories (LRU)
|
||||
- **Retrieval Cache**: Query result caching
|
||||
- **Embedding Cache**: Pre-computed embeddings
|
||||
|
||||
```python
|
||||
from app.services.memory.cache import CacheManager
|
||||
|
||||
cache = CacheManager(settings)
|
||||
await cache.warm_hot_cache(project_id) # Pre-warm common memories
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
Prometheus-compatible metrics:
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|--------|------|--------|
|
||||
| `memory_operations_total` | Counter | operation, memory_type, scope, success |
|
||||
| `memory_retrievals_total` | Counter | memory_type, strategy |
|
||||
| `memory_cache_hits_total` | Counter | cache_type |
|
||||
| `memory_retrieval_latency_seconds` | Histogram | - |
|
||||
| `memory_consolidation_duration_seconds` | Histogram | - |
|
||||
| `memory_items_count` | Gauge | memory_type, scope |
|
||||
|
||||
```python
|
||||
from app.services.memory.metrics import get_memory_metrics
|
||||
|
||||
metrics = await get_memory_metrics()
|
||||
summary = await metrics.get_summary()
|
||||
prometheus_output = await metrics.get_prometheus_format()
|
||||
```
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Operation | Target P95 |
|
||||
|-----------|------------|
|
||||
| Working memory get/set | < 5ms |
|
||||
| Episodic memory retrieval | < 100ms |
|
||||
| Semantic memory search | < 100ms |
|
||||
| Procedural memory matching | < 50ms |
|
||||
| Consolidation batch (1000 items) | < 30s |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Redis Connection Issues
|
||||
```bash
|
||||
# Check Redis connectivity
|
||||
redis-cli ping
|
||||
|
||||
# Verify memory settings
|
||||
MEM_REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
### Slow Retrieval
|
||||
1. Check if caching is enabled: `MEM_CACHE_ENABLED=true`
|
||||
2. Verify HNSW indexes exist on vector columns
|
||||
3. Monitor `memory_retrieval_latency_seconds` metric
|
||||
|
||||
### High Memory Usage
|
||||
1. Review `MEM_EPISODIC_MAX_EPISODES_PER_PROJECT` limit
|
||||
2. Ensure pruning is enabled: `MEM_PRUNING_ENABLED=true`
|
||||
3. Check consolidation is running (cron schedule)
|
||||
|
||||
### Embedding Errors
|
||||
1. Verify LLM Gateway is accessible
|
||||
2. Check embedding model is valid
|
||||
3. Review batch size if hitting rate limits
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/services/memory/
|
||||
├── __init__.py # Public exports
|
||||
├── config.py # MemorySettings
|
||||
├── exceptions.py # Memory-specific errors
|
||||
├── manager.py # MemoryManager facade
|
||||
├── types.py # Core types
|
||||
├── working/ # Working memory
|
||||
│ ├── memory.py
|
||||
│ └── storage.py
|
||||
├── episodic/ # Episodic memory
|
||||
│ ├── memory.py
|
||||
│ ├── recorder.py
|
||||
│ └── retrieval.py
|
||||
├── semantic/ # Semantic memory
|
||||
│ ├── memory.py
|
||||
│ ├── extraction.py
|
||||
│ └── verification.py
|
||||
├── procedural/ # Procedural memory
|
||||
│ ├── memory.py
|
||||
│ └── matching.py
|
||||
├── scoping/ # Memory scoping
|
||||
│ ├── scope.py
|
||||
│ └── resolver.py
|
||||
├── indexing/ # Indexing & retrieval
|
||||
│ ├── index.py
|
||||
│ └── retrieval.py
|
||||
├── consolidation/ # Memory consolidation
|
||||
│ └── service.py
|
||||
├── reflection/ # Memory reflection
|
||||
│ ├── service.py
|
||||
│ └── types.py
|
||||
├── integration/ # External integrations
|
||||
│ ├── context_source.py
|
||||
│ └── lifecycle.py
|
||||
├── cache/ # Caching layer
|
||||
│ ├── cache_manager.py
|
||||
│ ├── hot_cache.py
|
||||
│ └── embedding_cache.py
|
||||
├── mcp/ # MCP tools
|
||||
│ ├── service.py
|
||||
│ └── tools.py
|
||||
└── metrics/ # Observability
|
||||
└── collector.py
|
||||
```
|
||||
@@ -26,6 +26,7 @@ Usage:
|
||||
# Inside Docker (without --local flag):
|
||||
python migrate.py auto "Add new field"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
@@ -44,13 +45,14 @@ def setup_database_url(use_local: bool) -> str:
|
||||
# Override DATABASE_URL to use localhost instead of Docker hostname
|
||||
local_url = os.environ.get(
|
||||
"LOCAL_DATABASE_URL",
|
||||
"postgresql://postgres:postgres@localhost:5432/app"
|
||||
"postgresql://postgres:postgres@localhost:5432/syndarix",
|
||||
)
|
||||
os.environ["DATABASE_URL"] = local_url
|
||||
return local_url
|
||||
|
||||
# Use the configured DATABASE_URL from environment/.env
|
||||
from app.core.config import settings
|
||||
|
||||
return settings.database_url
|
||||
|
||||
|
||||
@@ -61,6 +63,7 @@ def check_models():
|
||||
try:
|
||||
# Import all models through the models package
|
||||
from app.models import __all__ as all_models
|
||||
|
||||
print(f"Found {len(all_models)} model(s):")
|
||||
for model in all_models:
|
||||
print(f" - {model}")
|
||||
@@ -110,7 +113,9 @@ def generate_migration(message, rev_id=None, auto_rev_id=True, offline=False):
|
||||
# Look for the revision ID, which is typically 12 hex characters
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
if len(part) >= 12 and all(c in "0123456789abcdef" for c in part[:12]):
|
||||
if len(part) >= 12 and all(
|
||||
c in "0123456789abcdef" for c in part[:12]
|
||||
):
|
||||
revision = part[:12]
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -185,6 +190,7 @@ def check_database_connection():
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if not db_url:
|
||||
from app.core.config import settings
|
||||
|
||||
db_url = settings.database_url
|
||||
|
||||
engine = create_engine(db_url)
|
||||
@@ -270,8 +276,8 @@ def generate_offline_migration(message, rev_id):
|
||||
content = f'''"""{message}
|
||||
|
||||
Revision ID: {rev_id}
|
||||
Revises: {down_revision or ''}
|
||||
Create Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}
|
||||
Revises: {down_revision or ""}
|
||||
Create Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")}
|
||||
|
||||
"""
|
||||
|
||||
@@ -320,6 +326,7 @@ def reset_alembic_version():
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if not db_url:
|
||||
from app.core.config import settings
|
||||
|
||||
db_url = settings.database_url
|
||||
|
||||
try:
|
||||
@@ -338,82 +345,80 @@ def reset_alembic_version():
|
||||
def main():
|
||||
"""Main function"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Database migration helper for Generative Models Arena'
|
||||
description="Database migration helper for Generative Models Arena"
|
||||
)
|
||||
|
||||
# Global options
|
||||
parser.add_argument(
|
||||
'--local', '-l',
|
||||
action='store_true',
|
||||
help='Use localhost instead of Docker hostname (for local development)'
|
||||
"--local",
|
||||
"-l",
|
||||
action="store_true",
|
||||
help="Use localhost instead of Docker hostname (for local development)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# Generate command
|
||||
generate_parser = subparsers.add_parser('generate', help='Generate a migration')
|
||||
generate_parser.add_argument('message', help='Migration message')
|
||||
generate_parser = subparsers.add_parser("generate", help="Generate a migration")
|
||||
generate_parser.add_argument("message", help="Migration message")
|
||||
generate_parser.add_argument(
|
||||
'--rev-id',
|
||||
help='Custom revision ID (e.g., 0001, 0002 for sequential naming)'
|
||||
"--rev-id", help="Custom revision ID (e.g., 0001, 0002 for sequential naming)"
|
||||
)
|
||||
generate_parser.add_argument(
|
||||
'--offline',
|
||||
action='store_true',
|
||||
help='Generate empty migration template without database connection'
|
||||
"--offline",
|
||||
action="store_true",
|
||||
help="Generate empty migration template without database connection",
|
||||
)
|
||||
|
||||
# Apply command
|
||||
apply_parser = subparsers.add_parser('apply', help='Apply migrations')
|
||||
apply_parser.add_argument('--revision', help='Specific revision to apply to')
|
||||
apply_parser = subparsers.add_parser("apply", help="Apply migrations")
|
||||
apply_parser.add_argument("--revision", help="Specific revision to apply to")
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List migrations')
|
||||
subparsers.add_parser("list", help="List migrations")
|
||||
|
||||
# Current command
|
||||
subparsers.add_parser('current', help='Show current revision')
|
||||
subparsers.add_parser("current", help="Show current revision")
|
||||
|
||||
# Check command
|
||||
subparsers.add_parser('check', help='Check database connection and models')
|
||||
subparsers.add_parser("check", help="Check database connection and models")
|
||||
|
||||
# Next command (show next revision ID)
|
||||
subparsers.add_parser('next', help='Show the next sequential revision ID')
|
||||
subparsers.add_parser("next", help="Show the next sequential revision ID")
|
||||
|
||||
# Reset command (clear alembic_version table)
|
||||
subparsers.add_parser(
|
||||
'reset',
|
||||
help='Reset alembic_version table (use after deleting all migrations)'
|
||||
"reset", help="Reset alembic_version table (use after deleting all migrations)"
|
||||
)
|
||||
|
||||
# Auto command (generate and apply)
|
||||
auto_parser = subparsers.add_parser('auto', help='Generate and apply migration')
|
||||
auto_parser.add_argument('message', help='Migration message')
|
||||
auto_parser = subparsers.add_parser("auto", help="Generate and apply migration")
|
||||
auto_parser.add_argument("message", help="Migration message")
|
||||
auto_parser.add_argument(
|
||||
'--rev-id',
|
||||
help='Custom revision ID (e.g., 0001, 0002 for sequential naming)'
|
||||
"--rev-id", help="Custom revision ID (e.g., 0001, 0002 for sequential naming)"
|
||||
)
|
||||
auto_parser.add_argument(
|
||||
'--offline',
|
||||
action='store_true',
|
||||
help='Generate empty migration template without database connection'
|
||||
"--offline",
|
||||
action="store_true",
|
||||
help="Generate empty migration template without database connection",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Commands that don't need database connection
|
||||
if args.command == 'next':
|
||||
if args.command == "next":
|
||||
show_next_rev_id()
|
||||
return
|
||||
|
||||
# Check if offline mode is requested
|
||||
offline = getattr(args, 'offline', False)
|
||||
offline = getattr(args, "offline", False)
|
||||
|
||||
# Offline generate doesn't need database or model check
|
||||
if args.command == 'generate' and offline:
|
||||
if args.command == "generate" and offline:
|
||||
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
||||
return
|
||||
|
||||
if args.command == 'auto' and offline:
|
||||
if args.command == "auto" and offline:
|
||||
generate_migration(args.message, rev_id=args.rev_id, offline=True)
|
||||
print("\nOffline migration generated. Apply it later with:")
|
||||
print(" python migrate.py --local apply")
|
||||
@@ -423,27 +428,27 @@ def main():
|
||||
db_url = setup_database_url(args.local)
|
||||
print(f"Using database URL: {db_url}")
|
||||
|
||||
if args.command == 'generate':
|
||||
if args.command == "generate":
|
||||
check_models()
|
||||
generate_migration(args.message, rev_id=args.rev_id)
|
||||
|
||||
elif args.command == 'apply':
|
||||
elif args.command == "apply":
|
||||
apply_migration(args.revision)
|
||||
|
||||
elif args.command == 'list':
|
||||
elif args.command == "list":
|
||||
list_migrations()
|
||||
|
||||
elif args.command == 'current':
|
||||
elif args.command == "current":
|
||||
show_current()
|
||||
|
||||
elif args.command == 'check':
|
||||
elif args.command == "check":
|
||||
check_database_connection()
|
||||
check_models()
|
||||
|
||||
elif args.command == 'reset':
|
||||
elif args.command == "reset":
|
||||
reset_alembic_version()
|
||||
|
||||
elif args.command == 'auto':
|
||||
elif args.command == "auto":
|
||||
check_models()
|
||||
revision = generate_migration(args.message, rev_id=args.rev_id)
|
||||
if revision:
|
||||
|
||||
@@ -745,3 +745,230 @@ class TestAgentTypeInstanceCount:
|
||||
for agent_type in data["data"]:
|
||||
assert "instance_count" in agent_type
|
||||
assert isinstance(agent_type["instance_count"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentTypeCategoryFields:
|
||||
"""Tests for agent type category and display fields."""
|
||||
|
||||
async def test_create_agent_type_with_category_fields(
|
||||
self, client, superuser_token
|
||||
):
|
||||
"""Test creating agent type with all category and display fields."""
|
||||
unique_slug = f"category-type-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Categorized Agent Type",
|
||||
"slug": unique_slug,
|
||||
"description": "An agent type with category fields",
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "You are a helpful assistant.",
|
||||
"primary_model": "claude-opus-4-5-20251101",
|
||||
# Category and display fields
|
||||
"category": "development",
|
||||
"icon": "code",
|
||||
"color": "#3B82F6",
|
||||
"sort_order": 10,
|
||||
"typical_tasks": ["Write code", "Review PRs"],
|
||||
"collaboration_hints": ["backend-engineer", "qa-engineer"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
|
||||
assert data["category"] == "development"
|
||||
assert data["icon"] == "code"
|
||||
assert data["color"] == "#3B82F6"
|
||||
assert data["sort_order"] == 10
|
||||
assert data["typical_tasks"] == ["Write code", "Review PRs"]
|
||||
assert data["collaboration_hints"] == ["backend-engineer", "qa-engineer"]
|
||||
|
||||
async def test_create_agent_type_with_nullable_category(
|
||||
self, client, superuser_token
|
||||
):
|
||||
"""Test creating agent type with null category."""
|
||||
unique_slug = f"null-category-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Uncategorized Agent",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["general"],
|
||||
"personality_prompt": "You are a helpful assistant.",
|
||||
"primary_model": "claude-opus-4-5-20251101",
|
||||
"category": None,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["category"] is None
|
||||
|
||||
async def test_create_agent_type_invalid_color_format(
|
||||
self, client, superuser_token
|
||||
):
|
||||
"""Test that invalid color format is rejected."""
|
||||
unique_slug = f"invalid-color-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Invalid Color Agent",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "You are a helpful assistant.",
|
||||
"primary_model": "claude-opus-4-5-20251101",
|
||||
"color": "not-a-hex-color",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_create_agent_type_invalid_category(self, client, superuser_token):
|
||||
"""Test that invalid category value is rejected."""
|
||||
unique_slug = f"invalid-category-{uuid.uuid4().hex[:8]}"
|
||||
response = await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": "Invalid Category Agent",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "You are a helpful assistant.",
|
||||
"primary_model": "claude-opus-4-5-20251101",
|
||||
"category": "not_a_valid_category",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_update_agent_type_category_fields(
|
||||
self, client, superuser_token, test_agent_type
|
||||
):
|
||||
"""Test updating category and display fields."""
|
||||
agent_type_id = test_agent_type["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/agent-types/{agent_type_id}",
|
||||
json={
|
||||
"category": "ai_ml",
|
||||
"icon": "brain",
|
||||
"color": "#8B5CF6",
|
||||
"sort_order": 50,
|
||||
"typical_tasks": ["Train models", "Analyze data"],
|
||||
"collaboration_hints": ["data-scientist"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
assert data["category"] == "ai_ml"
|
||||
assert data["icon"] == "brain"
|
||||
assert data["color"] == "#8B5CF6"
|
||||
assert data["sort_order"] == 50
|
||||
assert data["typical_tasks"] == ["Train models", "Analyze data"]
|
||||
assert data["collaboration_hints"] == ["data-scientist"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentTypeCategoryFilter:
|
||||
"""Tests for agent type category filtering."""
|
||||
|
||||
async def test_list_agent_types_filter_by_category(
|
||||
self, client, superuser_token, user_token
|
||||
):
|
||||
"""Test filtering agent types by category."""
|
||||
# Create agent types in different categories
|
||||
for cat in ["development", "design"]:
|
||||
unique_slug = f"filter-test-{cat}-{uuid.uuid4().hex[:8]}"
|
||||
await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": f"Filter Test {cat.capitalize()}",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Test prompt",
|
||||
"primary_model": "claude-opus-4-5-20251101",
|
||||
"category": cat,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
# Filter by development category
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types",
|
||||
params={"category": "development"},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
# All returned types should have development category
|
||||
for agent_type in data["data"]:
|
||||
assert agent_type["category"] == "development"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentTypeGroupedEndpoint:
|
||||
"""Tests for the grouped by category endpoint."""
|
||||
|
||||
async def test_list_agent_types_grouped(self, client, superuser_token, user_token):
|
||||
"""Test getting agent types grouped by category."""
|
||||
# Create agent types in different categories
|
||||
categories = ["development", "design", "quality"]
|
||||
for cat in categories:
|
||||
unique_slug = f"grouped-test-{cat}-{uuid.uuid4().hex[:8]}"
|
||||
await client.post(
|
||||
"/api/v1/agent-types",
|
||||
json={
|
||||
"name": f"Grouped Test {cat.capitalize()}",
|
||||
"slug": unique_slug,
|
||||
"expertise": ["python"],
|
||||
"personality_prompt": "Test prompt",
|
||||
"primary_model": "claude-opus-4-5-20251101",
|
||||
"category": cat,
|
||||
"sort_order": 10,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
# Get grouped agent types
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types/grouped",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
|
||||
# Should be a dict with category keys
|
||||
assert isinstance(data, dict)
|
||||
|
||||
# Check that at least one of our created categories exists
|
||||
assert any(cat in data for cat in categories)
|
||||
|
||||
async def test_list_agent_types_grouped_filter_inactive(
|
||||
self, client, superuser_token, user_token
|
||||
):
|
||||
"""Test grouped endpoint with is_active filter."""
|
||||
response = await client.get(
|
||||
"/api/v1/agent-types/grouped",
|
||||
params={"is_active": False},
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
async def test_list_agent_types_grouped_unauthenticated(self, client):
|
||||
"""Test that unauthenticated users cannot access grouped endpoint."""
|
||||
response = await client.get("/api/v1/agent-types/grouped")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@@ -188,13 +188,14 @@ class TestPasswordResetConfirm:
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_reset_confirm_expired_token(self, client, async_test_user):
|
||||
"""Test password reset confirmation with expired token."""
|
||||
import time as time_module
|
||||
import asyncio
|
||||
|
||||
# Create token that expires immediately
|
||||
token = create_password_reset_token(async_test_user.email, expires_in=1)
|
||||
# Create token that expires at current second (expires_in=0)
|
||||
# Token expires when exp < current_time, so we need to cross a second boundary
|
||||
token = create_password_reset_token(async_test_user.email, expires_in=0)
|
||||
|
||||
# Wait for token to expire
|
||||
time_module.sleep(2)
|
||||
# Wait for token to expire (need to cross second boundary)
|
||||
await asyncio.sleep(1.1)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/password-reset/confirm",
|
||||
|
||||
@@ -368,3 +368,9 @@ async def e2e_org_with_members(e2e_client, e2e_superuser):
|
||||
"user_id": member_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# NOTE: Class-scoped fixtures for E2E tests were attempted but have fundamental
|
||||
# issues with pytest-asyncio + SQLAlchemy/asyncpg event loop management.
|
||||
# The function-scoped fixtures above provide proper test isolation.
|
||||
# Performance optimization would require significant infrastructure changes.
|
||||
|
||||
@@ -316,3 +316,325 @@ class TestAgentTypeJsonFields:
|
||||
)
|
||||
|
||||
assert agent_type.fallback_models == models
|
||||
|
||||
|
||||
class TestAgentTypeCategoryFieldsValidation:
|
||||
"""Tests for AgentType category and display field validation."""
|
||||
|
||||
def test_valid_category_values(self):
|
||||
"""Test that all valid category values are accepted."""
|
||||
valid_categories = [
|
||||
"development",
|
||||
"design",
|
||||
"quality",
|
||||
"operations",
|
||||
"ai_ml",
|
||||
"data",
|
||||
"leadership",
|
||||
"domain_expert",
|
||||
]
|
||||
|
||||
for category in valid_categories:
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
category=category,
|
||||
)
|
||||
assert agent_type.category.value == category
|
||||
|
||||
def test_category_null_allowed(self):
|
||||
"""Test that null category is allowed."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
category=None,
|
||||
)
|
||||
assert agent_type.category is None
|
||||
|
||||
def test_invalid_category_rejected(self):
|
||||
"""Test that invalid category values are rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
category="invalid_category",
|
||||
)
|
||||
|
||||
def test_valid_hex_color(self):
|
||||
"""Test that valid hex colors are accepted."""
|
||||
valid_colors = ["#3B82F6", "#EC4899", "#10B981", "#ffffff", "#000000"]
|
||||
|
||||
for color in valid_colors:
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
color=color,
|
||||
)
|
||||
assert agent_type.color == color
|
||||
|
||||
def test_invalid_hex_color_rejected(self):
|
||||
"""Test that invalid hex colors are rejected."""
|
||||
invalid_colors = [
|
||||
"not-a-color",
|
||||
"3B82F6", # Missing #
|
||||
"#3B82F", # Too short
|
||||
"#3B82F6A", # Too long
|
||||
"#GGGGGG", # Invalid hex chars
|
||||
"rgb(59, 130, 246)", # RGB format not supported
|
||||
]
|
||||
|
||||
for color in invalid_colors:
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
color=color,
|
||||
)
|
||||
|
||||
def test_color_null_allowed(self):
|
||||
"""Test that null color is allowed."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
color=None,
|
||||
)
|
||||
assert agent_type.color is None
|
||||
|
||||
def test_sort_order_valid_range(self):
|
||||
"""Test that valid sort_order values are accepted."""
|
||||
for sort_order in [0, 1, 500, 1000]:
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
sort_order=sort_order,
|
||||
)
|
||||
assert agent_type.sort_order == sort_order
|
||||
|
||||
def test_sort_order_default_zero(self):
|
||||
"""Test that sort_order defaults to 0."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
)
|
||||
assert agent_type.sort_order == 0
|
||||
|
||||
def test_sort_order_negative_rejected(self):
|
||||
"""Test that negative sort_order is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
sort_order=-1,
|
||||
)
|
||||
|
||||
def test_sort_order_exceeds_max_rejected(self):
|
||||
"""Test that sort_order > 1000 is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
sort_order=1001,
|
||||
)
|
||||
|
||||
def test_icon_max_length(self):
|
||||
"""Test that icon field respects max length."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
icon="x" * 50,
|
||||
)
|
||||
assert len(agent_type.icon) == 50
|
||||
|
||||
def test_icon_exceeds_max_length_rejected(self):
|
||||
"""Test that icon exceeding max length is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
icon="x" * 51,
|
||||
)
|
||||
|
||||
|
||||
class TestAgentTypeTypicalTasksValidation:
|
||||
"""Tests for typical_tasks field validation."""
|
||||
|
||||
def test_typical_tasks_list(self):
|
||||
"""Test typical_tasks as a list."""
|
||||
tasks = ["Write code", "Review PRs", "Debug issues"]
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
typical_tasks=tasks,
|
||||
)
|
||||
assert agent_type.typical_tasks == tasks
|
||||
|
||||
def test_typical_tasks_default_empty(self):
|
||||
"""Test typical_tasks defaults to empty list."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
)
|
||||
assert agent_type.typical_tasks == []
|
||||
|
||||
def test_typical_tasks_strips_whitespace(self):
|
||||
"""Test that typical_tasks items are stripped."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
typical_tasks=[" Write code ", " Debug "],
|
||||
)
|
||||
assert agent_type.typical_tasks == ["Write code", "Debug"]
|
||||
|
||||
def test_typical_tasks_removes_empty_strings(self):
|
||||
"""Test that empty strings are removed from typical_tasks."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
typical_tasks=["Write code", "", " ", "Debug"],
|
||||
)
|
||||
assert agent_type.typical_tasks == ["Write code", "Debug"]
|
||||
|
||||
|
||||
class TestAgentTypeCollaborationHintsValidation:
|
||||
"""Tests for collaboration_hints field validation."""
|
||||
|
||||
def test_collaboration_hints_list(self):
|
||||
"""Test collaboration_hints as a list."""
|
||||
hints = ["backend-engineer", "qa-engineer"]
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
collaboration_hints=hints,
|
||||
)
|
||||
assert agent_type.collaboration_hints == hints
|
||||
|
||||
def test_collaboration_hints_default_empty(self):
|
||||
"""Test collaboration_hints defaults to empty list."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
)
|
||||
assert agent_type.collaboration_hints == []
|
||||
|
||||
def test_collaboration_hints_normalized_lowercase(self):
|
||||
"""Test that collaboration_hints are normalized to lowercase."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
collaboration_hints=["Backend-Engineer", "QA-ENGINEER"],
|
||||
)
|
||||
assert agent_type.collaboration_hints == ["backend-engineer", "qa-engineer"]
|
||||
|
||||
def test_collaboration_hints_strips_whitespace(self):
|
||||
"""Test that collaboration_hints are stripped."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
collaboration_hints=[" backend-engineer ", " qa-engineer "],
|
||||
)
|
||||
assert agent_type.collaboration_hints == ["backend-engineer", "qa-engineer"]
|
||||
|
||||
def test_collaboration_hints_removes_empty_strings(self):
|
||||
"""Test that empty strings are removed from collaboration_hints."""
|
||||
agent_type = AgentTypeCreate(
|
||||
name="Test Agent",
|
||||
slug="test-agent",
|
||||
personality_prompt="Test",
|
||||
primary_model="claude-opus-4-5-20251101",
|
||||
collaboration_hints=["backend-engineer", "", " ", "qa-engineer"],
|
||||
)
|
||||
assert agent_type.collaboration_hints == ["backend-engineer", "qa-engineer"]
|
||||
|
||||
|
||||
class TestAgentTypeUpdateCategoryFields:
|
||||
"""Tests for AgentTypeUpdate category and display fields."""
|
||||
|
||||
def test_update_category_field(self):
|
||||
"""Test updating category field."""
|
||||
update = AgentTypeUpdate(category="ai_ml")
|
||||
assert update.category.value == "ai_ml"
|
||||
|
||||
def test_update_icon_field(self):
|
||||
"""Test updating icon field."""
|
||||
update = AgentTypeUpdate(icon="brain")
|
||||
assert update.icon == "brain"
|
||||
|
||||
def test_update_color_field(self):
|
||||
"""Test updating color field."""
|
||||
update = AgentTypeUpdate(color="#8B5CF6")
|
||||
assert update.color == "#8B5CF6"
|
||||
|
||||
def test_update_sort_order_field(self):
|
||||
"""Test updating sort_order field."""
|
||||
update = AgentTypeUpdate(sort_order=50)
|
||||
assert update.sort_order == 50
|
||||
|
||||
def test_update_typical_tasks_field(self):
|
||||
"""Test updating typical_tasks field."""
|
||||
update = AgentTypeUpdate(typical_tasks=["New task"])
|
||||
assert update.typical_tasks == ["New task"]
|
||||
|
||||
def test_update_typical_tasks_strips_whitespace(self):
|
||||
"""Test that typical_tasks are stripped on update."""
|
||||
update = AgentTypeUpdate(typical_tasks=[" New task "])
|
||||
assert update.typical_tasks == ["New task"]
|
||||
|
||||
def test_update_collaboration_hints_field(self):
|
||||
"""Test updating collaboration_hints field."""
|
||||
update = AgentTypeUpdate(collaboration_hints=["new-collaborator"])
|
||||
assert update.collaboration_hints == ["new-collaborator"]
|
||||
|
||||
def test_update_collaboration_hints_normalized(self):
|
||||
"""Test that collaboration_hints are normalized on update."""
|
||||
update = AgentTypeUpdate(collaboration_hints=[" New-Collaborator "])
|
||||
assert update.collaboration_hints == ["new-collaborator"]
|
||||
|
||||
def test_update_invalid_color_rejected(self):
|
||||
"""Test that invalid color is rejected on update."""
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeUpdate(color="invalid")
|
||||
|
||||
def test_update_invalid_sort_order_rejected(self):
|
||||
"""Test that invalid sort_order is rejected on update."""
|
||||
with pytest.raises(ValidationError):
|
||||
AgentTypeUpdate(sort_order=-1)
|
||||
|
||||
@@ -304,10 +304,18 @@ class TestTaskModuleExports:
|
||||
assert hasattr(tasks, "sync")
|
||||
assert hasattr(tasks, "workflow")
|
||||
assert hasattr(tasks, "cost")
|
||||
assert hasattr(tasks, "memory_consolidation")
|
||||
|
||||
def test_tasks_all_attribute_is_correct(self):
|
||||
"""Test that __all__ contains all expected module names."""
|
||||
from app import tasks
|
||||
|
||||
expected_modules = ["agent", "git", "sync", "workflow", "cost"]
|
||||
expected_modules = [
|
||||
"agent",
|
||||
"git",
|
||||
"sync",
|
||||
"workflow",
|
||||
"cost",
|
||||
"memory_consolidation",
|
||||
]
|
||||
assert set(tasks.__all__) == set(expected_modules)
|
||||
|
||||
@@ -42,6 +42,9 @@ class TestInitDb:
|
||||
assert user.last_name == "User"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(
|
||||
reason="SQLite doesn't support UUID type binding - requires PostgreSQL"
|
||||
)
|
||||
async def test_init_db_returns_existing_superuser(
|
||||
self, async_test_db, async_test_user
|
||||
):
|
||||
|
||||
@@ -5,8 +5,6 @@ from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.context.types import ContextType
|
||||
from app.services.context.types.memory import MemoryContext, MemorySubtype
|
||||
|
||||
|
||||
@@ -160,11 +160,11 @@ class TestEmbeddingCache:
|
||||
|
||||
async def test_ttl_expiration(self) -> None:
|
||||
"""Should expire entries after TTL."""
|
||||
cache = EmbeddingCache(max_size=100, default_ttl_seconds=0.1)
|
||||
cache = EmbeddingCache(max_size=100, default_ttl_seconds=0.05)
|
||||
|
||||
await cache.put("content", [0.1, 0.2])
|
||||
|
||||
time.sleep(0.2)
|
||||
time.sleep(0.06)
|
||||
|
||||
result = await cache.get("content")
|
||||
|
||||
@@ -226,13 +226,13 @@ class TestEmbeddingCache:
|
||||
|
||||
def test_cleanup_expired(self) -> None:
|
||||
"""Should remove expired entries."""
|
||||
cache = EmbeddingCache(max_size=100, default_ttl_seconds=0.1)
|
||||
cache = EmbeddingCache(max_size=100, default_ttl_seconds=0.05)
|
||||
|
||||
# Use synchronous put for setup
|
||||
cache._put_memory("hash1", "default", [0.1])
|
||||
cache._put_memory("hash2", "default", [0.2], ttl_seconds=10)
|
||||
|
||||
time.sleep(0.2)
|
||||
time.sleep(0.06)
|
||||
|
||||
count = cache.cleanup_expired()
|
||||
|
||||
|
||||
@@ -212,12 +212,12 @@ class TestHotMemoryCache:
|
||||
|
||||
def test_ttl_expiration(self) -> None:
|
||||
"""Should expire entries after TTL."""
|
||||
cache = HotMemoryCache[str](max_size=100, default_ttl_seconds=0.1)
|
||||
cache = HotMemoryCache[str](max_size=100, default_ttl_seconds=0.05)
|
||||
|
||||
cache.put_by_id("test", "1", "value")
|
||||
|
||||
# Wait for expiration
|
||||
time.sleep(0.2)
|
||||
time.sleep(0.06)
|
||||
|
||||
result = cache.get_by_id("test", "1")
|
||||
|
||||
@@ -289,12 +289,12 @@ class TestHotMemoryCache:
|
||||
|
||||
def test_cleanup_expired(self) -> None:
|
||||
"""Should remove expired entries."""
|
||||
cache = HotMemoryCache[str](max_size=100, default_ttl_seconds=0.1)
|
||||
cache = HotMemoryCache[str](max_size=100, default_ttl_seconds=0.05)
|
||||
|
||||
cache.put_by_id("test", "1", "value1")
|
||||
cache.put_by_id("test", "2", "value2", ttl_seconds=10)
|
||||
|
||||
time.sleep(0.2)
|
||||
time.sleep(0.06)
|
||||
|
||||
count = cache.cleanup_expired()
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ class TestMemoryContextSource:
|
||||
)
|
||||
|
||||
assert result.by_type["working"] == 2
|
||||
assert all(
|
||||
c.memory_subtype == MemorySubtype.WORKING for c in result.contexts
|
||||
)
|
||||
assert all(c.memory_subtype == MemorySubtype.WORKING for c in result.contexts)
|
||||
|
||||
@patch("app.services.memory.integration.context_source.EpisodicMemory")
|
||||
async def test_fetch_episodic_memory(
|
||||
@@ -252,11 +250,10 @@ class TestMemoryContextSource:
|
||||
context_source: MemoryContextSource,
|
||||
) -> None:
|
||||
"""Results should be sorted by relevance score."""
|
||||
with patch.object(
|
||||
context_source, "_fetch_episodic"
|
||||
) as mock_ep, patch.object(
|
||||
context_source, "_fetch_semantic"
|
||||
) as mock_sem:
|
||||
with (
|
||||
patch.object(context_source, "_fetch_episodic") as mock_ep,
|
||||
patch.object(context_source, "_fetch_semantic") as mock_sem,
|
||||
):
|
||||
# Create contexts with different relevance scores
|
||||
from app.services.context.types.memory import MemoryContext
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ class TestLifecycleHooks:
|
||||
|
||||
def test_register_spawn_hook(self, lifecycle_hooks: LifecycleHooks) -> None:
|
||||
"""Should register spawn hook."""
|
||||
|
||||
async def my_hook(event: LifecycleEvent) -> None:
|
||||
pass
|
||||
|
||||
@@ -115,7 +116,7 @@ class TestLifecycleHooks:
|
||||
|
||||
def test_register_all_hooks(self, lifecycle_hooks: LifecycleHooks) -> None:
|
||||
"""Should register hooks for all event types."""
|
||||
hooks = [
|
||||
[
|
||||
lifecycle_hooks.on_spawn(AsyncMock()),
|
||||
lifecycle_hooks.on_pause(AsyncMock()),
|
||||
lifecycle_hooks.on_resume(AsyncMock()),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"""Tests for MemoryToolService."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -14,11 +13,6 @@ from app.services.memory.mcp.service import (
|
||||
ToolResult,
|
||||
get_memory_tool_service,
|
||||
)
|
||||
from app.services.memory.mcp.tools import (
|
||||
AnalysisType,
|
||||
MemoryType,
|
||||
OutcomeType,
|
||||
)
|
||||
from app.services.memory.types import Outcome
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="function")
|
||||
@@ -192,7 +186,9 @@ class TestMemoryToolService:
|
||||
context: ToolContext,
|
||||
) -> None:
|
||||
"""Remember should store in episodic memory."""
|
||||
with patch("app.services.memory.mcp.service.EpisodicMemory") as mock_episodic_cls:
|
||||
with patch(
|
||||
"app.services.memory.mcp.service.EpisodicMemory"
|
||||
) as mock_episodic_cls:
|
||||
# Setup mock
|
||||
mock_episode = MagicMock()
|
||||
mock_episode.id = uuid4()
|
||||
@@ -260,7 +256,9 @@ class TestMemoryToolService:
|
||||
context: ToolContext,
|
||||
) -> None:
|
||||
"""Remember should store facts in semantic memory."""
|
||||
with patch("app.services.memory.mcp.service.SemanticMemory") as mock_semantic_cls:
|
||||
with patch(
|
||||
"app.services.memory.mcp.service.SemanticMemory"
|
||||
) as mock_semantic_cls:
|
||||
mock_fact = MagicMock()
|
||||
mock_fact.id = uuid4()
|
||||
|
||||
@@ -311,7 +309,9 @@ class TestMemoryToolService:
|
||||
context: ToolContext,
|
||||
) -> None:
|
||||
"""Remember should store procedures in procedural memory."""
|
||||
with patch("app.services.memory.mcp.service.ProceduralMemory") as mock_procedural_cls:
|
||||
with patch(
|
||||
"app.services.memory.mcp.service.ProceduralMemory"
|
||||
) as mock_procedural_cls:
|
||||
mock_procedure = MagicMock()
|
||||
mock_procedure.id = uuid4()
|
||||
|
||||
@@ -530,15 +530,21 @@ class TestMemoryToolService:
|
||||
mock_working_cls.for_session = AsyncMock(return_value=mock_working)
|
||||
|
||||
mock_episodic = AsyncMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=[MagicMock() for _ in range(10)])
|
||||
mock_episodic.get_recent = AsyncMock(
|
||||
return_value=[MagicMock() for _ in range(10)]
|
||||
)
|
||||
mock_episodic_cls.create = AsyncMock(return_value=mock_episodic)
|
||||
|
||||
mock_semantic = AsyncMock()
|
||||
mock_semantic.search_facts = AsyncMock(return_value=[MagicMock() for _ in range(5)])
|
||||
mock_semantic.search_facts = AsyncMock(
|
||||
return_value=[MagicMock() for _ in range(5)]
|
||||
)
|
||||
mock_semantic_cls.create = AsyncMock(return_value=mock_semantic)
|
||||
|
||||
mock_procedural = AsyncMock()
|
||||
mock_procedural.find_matching = AsyncMock(return_value=[MagicMock() for _ in range(3)])
|
||||
mock_procedural.find_matching = AsyncMock(
|
||||
return_value=[MagicMock() for _ in range(3)]
|
||||
)
|
||||
mock_procedural_cls.create = AsyncMock(return_value=mock_procedural)
|
||||
|
||||
result = await service.execute_tool(
|
||||
@@ -603,8 +609,12 @@ class TestMemoryToolService:
|
||||
) -> None:
|
||||
"""Record outcome should store outcome and update procedure."""
|
||||
with (
|
||||
patch("app.services.memory.mcp.service.EpisodicMemory") as mock_episodic_cls,
|
||||
patch("app.services.memory.mcp.service.ProceduralMemory") as mock_procedural_cls,
|
||||
patch(
|
||||
"app.services.memory.mcp.service.EpisodicMemory"
|
||||
) as mock_episodic_cls,
|
||||
patch(
|
||||
"app.services.memory.mcp.service.ProceduralMemory"
|
||||
) as mock_procedural_cls,
|
||||
):
|
||||
mock_episode = MagicMock()
|
||||
mock_episode.id = uuid4()
|
||||
|
||||
@@ -358,10 +358,12 @@ class TestMemoryToolDefinition:
|
||||
)
|
||||
|
||||
# Valid args
|
||||
validated = tool.validate_args({
|
||||
"memory_type": "working",
|
||||
"content": "Test content",
|
||||
})
|
||||
validated = tool.validate_args(
|
||||
{
|
||||
"memory_type": "working",
|
||||
"content": "Test content",
|
||||
}
|
||||
)
|
||||
assert isinstance(validated, RememberArgs)
|
||||
|
||||
# Invalid args
|
||||
@@ -417,4 +419,6 @@ class TestToolDefinitions:
|
||||
"""All tool schemas should have properties defined."""
|
||||
for name, tool in MEMORY_TOOL_DEFINITIONS.items():
|
||||
schema = tool.to_mcp_format()
|
||||
assert "properties" in schema["inputSchema"], f"Tool {name} missing properties"
|
||||
assert "properties" in schema["inputSchema"], (
|
||||
f"Tool {name} missing properties"
|
||||
)
|
||||
|
||||
2
backend/tests/unit/services/memory/metrics/__init__.py
Normal file
2
backend/tests/unit/services/memory/metrics/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# tests/unit/services/memory/metrics/__init__.py
|
||||
"""Tests for Memory Metrics."""
|
||||
472
backend/tests/unit/services/memory/metrics/test_collector.py
Normal file
472
backend/tests/unit/services/memory/metrics/test_collector.py
Normal file
@@ -0,0 +1,472 @@
|
||||
# tests/unit/services/memory/metrics/test_collector.py
|
||||
"""Tests for Memory Metrics Collector."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.memory.metrics.collector import (
|
||||
MemoryMetrics,
|
||||
MetricType,
|
||||
MetricValue,
|
||||
get_memory_metrics,
|
||||
record_memory_operation,
|
||||
record_retrieval,
|
||||
reset_memory_metrics,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics() -> MemoryMetrics:
|
||||
"""Create a fresh metrics instance for each test."""
|
||||
return MemoryMetrics()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def reset_singleton() -> None:
|
||||
"""Reset singleton before each test."""
|
||||
await reset_memory_metrics()
|
||||
|
||||
|
||||
class TestMemoryMetrics:
|
||||
"""Tests for MemoryMetrics class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inc_operations(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should increment operation counters."""
|
||||
await metrics.inc_operations("get", "working", "session", True)
|
||||
await metrics.inc_operations("get", "working", "session", True)
|
||||
await metrics.inc_operations("set", "working", "session", True)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_operations"] == 3
|
||||
assert summary["successful_operations"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inc_operations_failure(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should track failed operations."""
|
||||
await metrics.inc_operations("get", "working", None, True)
|
||||
await metrics.inc_operations("get", "working", None, False)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_operations"] == 2
|
||||
assert summary["successful_operations"] == 1
|
||||
assert summary["operation_success_rate"] == 0.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inc_retrieval(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should increment retrieval counters."""
|
||||
await metrics.inc_retrieval("episodic", "similarity", 5)
|
||||
await metrics.inc_retrieval("episodic", "temporal", 3)
|
||||
await metrics.inc_retrieval("semantic", "similarity", 10)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_retrievals"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_miss(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should track cache hits and misses."""
|
||||
await metrics.inc_cache_hit("hot")
|
||||
await metrics.inc_cache_hit("hot")
|
||||
await metrics.inc_cache_hit("hot")
|
||||
await metrics.inc_cache_miss("hot")
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["cache_hit_rate"] == 0.75
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_stats(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should provide detailed cache stats."""
|
||||
await metrics.inc_cache_hit("hot")
|
||||
await metrics.inc_cache_hit("hot")
|
||||
await metrics.inc_cache_miss("hot")
|
||||
await metrics.inc_cache_hit("embedding")
|
||||
await metrics.inc_cache_miss("embedding")
|
||||
await metrics.inc_cache_miss("embedding")
|
||||
|
||||
stats = await metrics.get_cache_stats()
|
||||
|
||||
assert stats["hot"]["hits"] == 2
|
||||
assert stats["hot"]["misses"] == 1
|
||||
assert stats["hot"]["hit_rate"] == pytest.approx(0.6667, rel=0.01)
|
||||
|
||||
assert stats["embedding"]["hits"] == 1
|
||||
assert stats["embedding"]["misses"] == 2
|
||||
assert stats["embedding"]["hit_rate"] == pytest.approx(0.3333, rel=0.01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inc_consolidation(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should increment consolidation counter."""
|
||||
await metrics.inc_consolidation("working_to_episodic", True)
|
||||
await metrics.inc_consolidation("episodic_to_semantic", True)
|
||||
await metrics.inc_consolidation("prune", False)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_consolidations"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inc_episodes_recorded(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should track episodes by outcome."""
|
||||
await metrics.inc_episodes_recorded("success")
|
||||
await metrics.inc_episodes_recorded("success")
|
||||
await metrics.inc_episodes_recorded("failure")
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_episodes_recorded"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inc_patterns_insights_anomalies(
|
||||
self, metrics: MemoryMetrics
|
||||
) -> None:
|
||||
"""Should track reflection metrics."""
|
||||
await metrics.inc_patterns_detected("recurring_success")
|
||||
await metrics.inc_patterns_detected("action_sequence")
|
||||
await metrics.inc_insights_generated("optimization")
|
||||
await metrics.inc_anomalies_detected("unusual_duration")
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["patterns_detected"] == 2
|
||||
assert summary["insights_generated"] == 1
|
||||
assert summary["anomalies_detected"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_memory_items_count(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should set memory item count gauge."""
|
||||
await metrics.set_memory_items_count("episodic", "project", 100)
|
||||
await metrics.set_memory_items_count("semantic", "project", 50)
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
gauge_metrics = [m for m in all_metrics if m.name == "memory_items_count"]
|
||||
|
||||
assert len(gauge_metrics) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_memory_size_bytes(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should set memory size gauge."""
|
||||
await metrics.set_memory_size_bytes("working", "session", 1024)
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
size_metrics = [m for m in all_metrics if m.name == "memory_size_bytes"]
|
||||
|
||||
assert len(size_metrics) == 1
|
||||
assert size_metrics[0].value == 1024.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_procedure_success_rate(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should set procedure success rate gauge."""
|
||||
await metrics.set_procedure_success_rate("code_review", 0.85)
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
rate_metrics = [
|
||||
m for m in all_metrics if m.name == "memory_procedure_success_rate"
|
||||
]
|
||||
|
||||
assert len(rate_metrics) == 1
|
||||
assert rate_metrics[0].value == 0.85
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_sessions(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should set active sessions gauge."""
|
||||
await metrics.set_active_sessions(5)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["active_sessions"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_working_latency(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should record working memory latency histogram."""
|
||||
await metrics.observe_working_latency(0.005) # 5ms
|
||||
await metrics.observe_working_latency(0.003) # 3ms
|
||||
await metrics.observe_working_latency(0.010) # 10ms
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
count_metric = next(
|
||||
(
|
||||
m
|
||||
for m in all_metrics
|
||||
if m.name == "memory_working_latency_seconds_count"
|
||||
),
|
||||
None,
|
||||
)
|
||||
sum_metric = next(
|
||||
(m for m in all_metrics if m.name == "memory_working_latency_seconds_sum"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert count_metric is not None
|
||||
assert count_metric.value == 3
|
||||
assert sum_metric is not None
|
||||
assert sum_metric.value == pytest.approx(0.018, rel=0.01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_retrieval_latency(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should record retrieval latency histogram."""
|
||||
await metrics.observe_retrieval_latency(0.050) # 50ms
|
||||
await metrics.observe_retrieval_latency(0.075) # 75ms
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["avg_retrieval_latency_ms"] == pytest.approx(62.5, rel=0.01)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_consolidation_duration(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should record consolidation duration histogram."""
|
||||
await metrics.observe_consolidation_duration(5.0)
|
||||
await metrics.observe_consolidation_duration(10.0)
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
count_metric = next(
|
||||
(
|
||||
m
|
||||
for m in all_metrics
|
||||
if m.name == "memory_consolidation_duration_seconds_count"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert count_metric is not None
|
||||
assert count_metric.value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_metrics(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should return all metrics as MetricValue objects."""
|
||||
await metrics.inc_operations("get", "working", None, True)
|
||||
await metrics.set_active_sessions(3)
|
||||
await metrics.observe_retrieval_latency(0.05)
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
|
||||
assert len(all_metrics) >= 3
|
||||
|
||||
# Check we have different metric types
|
||||
counter_metrics = [
|
||||
m for m in all_metrics if m.metric_type == MetricType.COUNTER
|
||||
]
|
||||
gauge_metrics = [m for m in all_metrics if m.metric_type == MetricType.GAUGE]
|
||||
|
||||
assert len(counter_metrics) >= 1
|
||||
assert len(gauge_metrics) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_prometheus_format(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should export metrics in Prometheus format."""
|
||||
await metrics.inc_operations("get", "working", "session", True)
|
||||
await metrics.set_active_sessions(5)
|
||||
|
||||
prometheus_output = await metrics.get_prometheus_format()
|
||||
|
||||
assert "# TYPE memory_operations_total counter" in prometheus_output
|
||||
assert "memory_operations_total{" in prometheus_output
|
||||
assert "# TYPE memory_active_sessions gauge" in prometheus_output
|
||||
assert "memory_active_sessions 5" in prometheus_output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_summary(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should return summary dictionary."""
|
||||
await metrics.inc_operations("get", "working", None, True)
|
||||
await metrics.inc_retrieval("episodic", "similarity", 5)
|
||||
await metrics.inc_cache_hit("hot")
|
||||
await metrics.inc_consolidation("prune", True)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
|
||||
assert "total_operations" in summary
|
||||
assert "total_retrievals" in summary
|
||||
assert "cache_hit_rate" in summary
|
||||
assert "total_consolidations" in summary
|
||||
assert "operation_success_rate" in summary
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should reset all metrics."""
|
||||
await metrics.inc_operations("get", "working", None, True)
|
||||
await metrics.set_active_sessions(5)
|
||||
await metrics.observe_retrieval_latency(0.05)
|
||||
|
||||
await metrics.reset()
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_operations"] == 0
|
||||
assert summary["active_sessions"] == 0
|
||||
|
||||
|
||||
class TestMetricValue:
|
||||
"""Tests for MetricValue dataclass."""
|
||||
|
||||
def test_creates_metric_value(self) -> None:
|
||||
"""Should create metric value with defaults."""
|
||||
metric = MetricValue(
|
||||
name="test_metric",
|
||||
metric_type=MetricType.COUNTER,
|
||||
value=42.0,
|
||||
)
|
||||
|
||||
assert metric.name == "test_metric"
|
||||
assert metric.metric_type == MetricType.COUNTER
|
||||
assert metric.value == 42.0
|
||||
assert metric.labels == {}
|
||||
assert metric.timestamp is not None
|
||||
|
||||
def test_creates_metric_value_with_labels(self) -> None:
|
||||
"""Should create metric value with labels."""
|
||||
metric = MetricValue(
|
||||
name="test_metric",
|
||||
metric_type=MetricType.GAUGE,
|
||||
value=100.0,
|
||||
labels={"scope": "project", "type": "episodic"},
|
||||
)
|
||||
|
||||
assert metric.labels == {"scope": "project", "type": "episodic"}
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
"""Tests for singleton pattern."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_metrics_singleton(self) -> None:
|
||||
"""Should return same instance."""
|
||||
metrics1 = await get_memory_metrics()
|
||||
metrics2 = await get_memory_metrics()
|
||||
|
||||
assert metrics1 is metrics2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_singleton(self) -> None:
|
||||
"""Should reset singleton instance."""
|
||||
metrics1 = await get_memory_metrics()
|
||||
await metrics1.inc_operations("get", "working", None, True)
|
||||
|
||||
await reset_memory_metrics()
|
||||
|
||||
metrics2 = await get_memory_metrics()
|
||||
summary = await metrics2.get_summary()
|
||||
|
||||
assert metrics1 is not metrics2
|
||||
assert summary["total_operations"] == 0
|
||||
|
||||
|
||||
class TestConvenienceFunctions:
|
||||
"""Tests for convenience functions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_memory_operation(self) -> None:
|
||||
"""Should record memory operation."""
|
||||
await record_memory_operation(
|
||||
operation="get",
|
||||
memory_type="working",
|
||||
scope="session",
|
||||
success=True,
|
||||
latency_ms=5.0,
|
||||
)
|
||||
|
||||
metrics = await get_memory_metrics()
|
||||
summary = await metrics.get_summary()
|
||||
|
||||
assert summary["total_operations"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_retrieval(self) -> None:
|
||||
"""Should record retrieval operation."""
|
||||
await record_retrieval(
|
||||
memory_type="episodic",
|
||||
strategy="similarity",
|
||||
results_count=10,
|
||||
latency_ms=50.0,
|
||||
)
|
||||
|
||||
metrics = await get_memory_metrics()
|
||||
summary = await metrics.get_summary()
|
||||
|
||||
assert summary["total_retrievals"] == 1
|
||||
assert summary["avg_retrieval_latency_ms"] == pytest.approx(50.0, rel=0.01)
|
||||
|
||||
|
||||
class TestHistogramBuckets:
|
||||
"""Tests for histogram bucket behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_histogram_buckets_populated(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should populate histogram buckets correctly."""
|
||||
# Add values to different buckets
|
||||
await metrics.observe_retrieval_latency(0.005) # <= 0.01
|
||||
await metrics.observe_retrieval_latency(0.030) # <= 0.05
|
||||
await metrics.observe_retrieval_latency(0.080) # <= 0.1
|
||||
await metrics.observe_retrieval_latency(0.500) # <= 0.5
|
||||
await metrics.observe_retrieval_latency(2.000) # <= 2.5
|
||||
|
||||
prometheus_output = await metrics.get_prometheus_format()
|
||||
|
||||
# Check that histogram buckets are in output
|
||||
assert "memory_retrieval_latency_seconds_bucket" in prometheus_output
|
||||
assert 'le="0.01"' in prometheus_output
|
||||
assert 'le="+Inf"' in prometheus_output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_histogram_count_and_sum(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should track histogram count and sum."""
|
||||
await metrics.observe_retrieval_latency(0.1)
|
||||
await metrics.observe_retrieval_latency(0.2)
|
||||
await metrics.observe_retrieval_latency(0.3)
|
||||
|
||||
prometheus_output = await metrics.get_prometheus_format()
|
||||
|
||||
assert "memory_retrieval_latency_seconds_count 3" in prometheus_output
|
||||
assert "memory_retrieval_latency_seconds_sum 0.6" in prometheus_output
|
||||
|
||||
|
||||
class TestLabelParsing:
|
||||
"""Tests for label parsing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_labels_in_output(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should correctly parse labels in output."""
|
||||
await metrics.inc_operations("get", "episodic", "project", True)
|
||||
|
||||
all_metrics = await metrics.get_all_metrics()
|
||||
op_metric = next(
|
||||
(m for m in all_metrics if m.name == "memory_operations_total"), None
|
||||
)
|
||||
|
||||
assert op_metric is not None
|
||||
assert op_metric.labels["operation"] == "get"
|
||||
assert op_metric.labels["memory_type"] == "episodic"
|
||||
assert op_metric.labels["scope"] == "project"
|
||||
assert op_metric.labels["success"] == "true"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_metrics(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should handle empty metrics gracefully."""
|
||||
summary = await metrics.get_summary()
|
||||
|
||||
assert summary["total_operations"] == 0
|
||||
assert summary["operation_success_rate"] == 1.0 # Default when no ops
|
||||
assert summary["cache_hit_rate"] == 0.0
|
||||
assert summary["avg_retrieval_latency_ms"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_operations(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should handle concurrent operations safely."""
|
||||
import asyncio
|
||||
|
||||
async def increment_ops() -> None:
|
||||
for _ in range(100):
|
||||
await metrics.inc_operations("get", "working", None, True)
|
||||
|
||||
# Run multiple concurrent tasks
|
||||
await asyncio.gather(
|
||||
increment_ops(),
|
||||
increment_ops(),
|
||||
increment_ops(),
|
||||
)
|
||||
|
||||
summary = await metrics.get_summary()
|
||||
assert summary["total_operations"] == 300
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_format_empty(self, metrics: MemoryMetrics) -> None:
|
||||
"""Should return valid format with no metrics."""
|
||||
prometheus_output = await metrics.get_prometheus_format()
|
||||
|
||||
# Should just have histogram bucket definitions
|
||||
assert "# TYPE memory_retrieval_latency_seconds histogram" in prometheus_output
|
||||
@@ -0,0 +1,2 @@
|
||||
# tests/unit/services/memory/reflection/__init__.py
|
||||
"""Tests for Memory Reflection."""
|
||||
769
backend/tests/unit/services/memory/reflection/test_service.py
Normal file
769
backend/tests/unit/services/memory/reflection/test_service.py
Normal file
@@ -0,0 +1,769 @@
|
||||
# tests/unit/services/memory/reflection/test_service.py
|
||||
"""Tests for Memory Reflection service."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.memory.reflection.service import (
|
||||
MemoryReflection,
|
||||
ReflectionConfig,
|
||||
get_memory_reflection,
|
||||
reset_memory_reflection,
|
||||
)
|
||||
from app.services.memory.reflection.types import (
|
||||
AnomalyType,
|
||||
FactorType,
|
||||
InsightType,
|
||||
PatternType,
|
||||
TimeRange,
|
||||
)
|
||||
from app.services.memory.types import Episode, Outcome
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="function")
|
||||
|
||||
|
||||
def create_mock_episode(
|
||||
task_type: str = "test_task",
|
||||
outcome: Outcome = Outcome.SUCCESS,
|
||||
duration_seconds: float = 60.0,
|
||||
tokens_used: int = 100,
|
||||
actions: list | None = None,
|
||||
occurred_at: datetime | None = None,
|
||||
context_summary: str = "Test context",
|
||||
) -> Episode:
|
||||
"""Create a mock episode for testing."""
|
||||
return Episode(
|
||||
id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
agent_instance_id=None,
|
||||
agent_type_id=None,
|
||||
session_id="session-123",
|
||||
task_type=task_type,
|
||||
task_description=f"Test {task_type}",
|
||||
actions=actions or [{"type": "action1", "content": "test"}],
|
||||
context_summary=context_summary,
|
||||
outcome=outcome,
|
||||
outcome_details="",
|
||||
duration_seconds=duration_seconds,
|
||||
tokens_used=tokens_used,
|
||||
lessons_learned=[],
|
||||
importance_score=0.5,
|
||||
embedding=None,
|
||||
occurred_at=occurred_at or datetime.now(UTC),
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def reset_singleton() -> None:
|
||||
"""Reset singleton before each test."""
|
||||
await reset_memory_reflection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session() -> MagicMock:
|
||||
"""Create mock database session."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config() -> ReflectionConfig:
|
||||
"""Create test configuration."""
|
||||
return ReflectionConfig(
|
||||
min_pattern_occurrences=2,
|
||||
min_pattern_confidence=0.5,
|
||||
min_sample_size_for_factor=3,
|
||||
min_correlation_for_factor=0.2,
|
||||
min_baseline_samples=5,
|
||||
anomaly_std_dev_threshold=2.0,
|
||||
min_insight_confidence=0.1, # Lower for testing
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reflection(mock_session: MagicMock, config: ReflectionConfig) -> MemoryReflection:
|
||||
"""Create reflection service."""
|
||||
return MemoryReflection(session=mock_session, config=config)
|
||||
|
||||
|
||||
class TestReflectionConfig:
|
||||
"""Tests for ReflectionConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Should have sensible defaults."""
|
||||
config = ReflectionConfig()
|
||||
|
||||
assert config.min_pattern_occurrences == 3
|
||||
assert config.min_pattern_confidence == 0.6
|
||||
assert config.min_sample_size_for_factor == 5
|
||||
assert config.anomaly_std_dev_threshold == 2.0
|
||||
assert config.max_episodes_to_analyze == 1000
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""Should allow custom values."""
|
||||
config = ReflectionConfig(
|
||||
min_pattern_occurrences=5,
|
||||
min_pattern_confidence=0.8,
|
||||
)
|
||||
|
||||
assert config.min_pattern_occurrences == 5
|
||||
assert config.min_pattern_confidence == 0.8
|
||||
|
||||
|
||||
class TestPatternDetection:
|
||||
"""Tests for pattern detection."""
|
||||
|
||||
async def test_detect_recurring_success_pattern(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect recurring success patterns."""
|
||||
project_id = uuid4()
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
# Create episodes with high success rate for a task type
|
||||
# Ensure timestamps are within time range
|
||||
now = datetime.now(UTC)
|
||||
episodes = [
|
||||
create_mock_episode(
|
||||
task_type="build",
|
||||
outcome=Outcome.SUCCESS,
|
||||
occurred_at=now - timedelta(hours=i),
|
||||
)
|
||||
for i in range(8)
|
||||
] + [
|
||||
create_mock_episode(
|
||||
task_type="build",
|
||||
outcome=Outcome.FAILURE,
|
||||
occurred_at=now - timedelta(hours=8 + i),
|
||||
)
|
||||
for i in range(2)
|
||||
]
|
||||
|
||||
# Mock episodic memory
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
patterns = await reflection.analyze_patterns(project_id, time_range)
|
||||
|
||||
# Should find recurring success pattern for 'build' task
|
||||
success_patterns = [
|
||||
p for p in patterns if p.pattern_type == PatternType.RECURRING_SUCCESS
|
||||
]
|
||||
assert len(success_patterns) >= 1
|
||||
assert any(p.name.find("build") >= 0 for p in success_patterns)
|
||||
|
||||
async def test_detect_recurring_failure_pattern(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect recurring failure patterns."""
|
||||
project_id = uuid4()
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
# Create episodes with high failure rate
|
||||
# Ensure timestamps are within time range
|
||||
now = datetime.now(UTC)
|
||||
episodes = [
|
||||
create_mock_episode(
|
||||
task_type="deploy",
|
||||
outcome=Outcome.FAILURE,
|
||||
occurred_at=now - timedelta(hours=i),
|
||||
)
|
||||
for i in range(7)
|
||||
] + [
|
||||
create_mock_episode(
|
||||
task_type="deploy",
|
||||
outcome=Outcome.SUCCESS,
|
||||
occurred_at=now - timedelta(hours=7 + i),
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
patterns = await reflection.analyze_patterns(project_id, time_range)
|
||||
|
||||
failure_patterns = [
|
||||
p for p in patterns if p.pattern_type == PatternType.RECURRING_FAILURE
|
||||
]
|
||||
assert len(failure_patterns) >= 1
|
||||
|
||||
async def test_detect_action_sequence_pattern(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect action sequence patterns."""
|
||||
project_id = uuid4()
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
# Create episodes with same action sequence
|
||||
# Ensure timestamps are within time range
|
||||
now = datetime.now(UTC)
|
||||
actions = [
|
||||
{"type": "read_file"},
|
||||
{"type": "analyze"},
|
||||
{"type": "write_file"},
|
||||
]
|
||||
episodes = [
|
||||
create_mock_episode(
|
||||
actions=actions,
|
||||
occurred_at=now - timedelta(hours=i),
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
patterns = await reflection.analyze_patterns(project_id, time_range)
|
||||
|
||||
action_patterns = [
|
||||
p for p in patterns if p.pattern_type == PatternType.ACTION_SEQUENCE
|
||||
]
|
||||
assert len(action_patterns) >= 1
|
||||
|
||||
async def test_detect_temporal_pattern(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect temporal patterns."""
|
||||
project_id = uuid4()
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
# Create episodes concentrated at a specific hour
|
||||
base_time = datetime.now(UTC).replace(hour=10, minute=0)
|
||||
episodes = [
|
||||
create_mock_episode(occurred_at=base_time + timedelta(minutes=i * 5))
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
patterns = await reflection.analyze_patterns(project_id, time_range)
|
||||
|
||||
# May or may not find temporal patterns depending on thresholds
|
||||
# Just verify the analysis completes without error
|
||||
assert isinstance(patterns, list)
|
||||
|
||||
async def test_empty_episodes_returns_empty(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should return empty list when no episodes."""
|
||||
project_id = uuid4()
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=[])
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
patterns = await reflection.analyze_patterns(project_id, time_range)
|
||||
|
||||
assert patterns == []
|
||||
|
||||
|
||||
class TestSuccessFactors:
|
||||
"""Tests for success factor identification."""
|
||||
|
||||
async def test_identify_action_factors(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should identify action-related success factors."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Create episodes where 'validate' action correlates with success
|
||||
successful = [
|
||||
create_mock_episode(
|
||||
outcome=Outcome.SUCCESS,
|
||||
actions=[{"type": "validate"}, {"type": "commit"}],
|
||||
)
|
||||
for _ in range(5)
|
||||
]
|
||||
failed = [
|
||||
create_mock_episode(
|
||||
outcome=Outcome.FAILURE,
|
||||
actions=[{"type": "commit"}], # Missing validate
|
||||
)
|
||||
for _ in range(5)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=successful + failed)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
factors = await reflection.identify_success_factors(project_id)
|
||||
|
||||
action_factors = [f for f in factors if f.factor_type == FactorType.ACTION]
|
||||
assert len(action_factors) >= 0 # May or may not find based on thresholds
|
||||
|
||||
async def test_identify_timing_factors(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should identify timing-related factors."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Successful tasks are faster
|
||||
successful = [
|
||||
create_mock_episode(outcome=Outcome.SUCCESS, duration_seconds=30.0)
|
||||
for _ in range(5)
|
||||
]
|
||||
# Failed tasks take longer
|
||||
failed = [
|
||||
create_mock_episode(outcome=Outcome.FAILURE, duration_seconds=120.0)
|
||||
for _ in range(5)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=successful + failed)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
factors = await reflection.identify_success_factors(project_id)
|
||||
|
||||
timing_factors = [f for f in factors if f.factor_type == FactorType.TIMING]
|
||||
assert len(timing_factors) >= 1
|
||||
|
||||
async def test_identify_resource_factors(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should identify resource usage factors."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Successful tasks use fewer tokens
|
||||
successful = [
|
||||
create_mock_episode(outcome=Outcome.SUCCESS, tokens_used=100)
|
||||
for _ in range(5)
|
||||
]
|
||||
# Failed tasks use more tokens
|
||||
failed = [
|
||||
create_mock_episode(outcome=Outcome.FAILURE, tokens_used=500)
|
||||
for _ in range(5)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=successful + failed)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
factors = await reflection.identify_success_factors(project_id)
|
||||
|
||||
resource_factors = [f for f in factors if f.factor_type == FactorType.RESOURCE]
|
||||
assert len(resource_factors) >= 1
|
||||
|
||||
async def test_filter_by_task_type(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should filter by task type when specified."""
|
||||
project_id = uuid4()
|
||||
|
||||
episodes = [
|
||||
create_mock_episode(task_type="target_task", outcome=Outcome.SUCCESS)
|
||||
for _ in range(5)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_by_task_type = AsyncMock(return_value=episodes)
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
await reflection.identify_success_factors(project_id, task_type="target_task")
|
||||
|
||||
mock_episodic.get_by_task_type.assert_called_once()
|
||||
|
||||
async def test_insufficient_samples(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should return empty when insufficient samples."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Only 2 episodes, config requires 3 minimum
|
||||
episodes = [create_mock_episode() for _ in range(2)]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
factors = await reflection.identify_success_factors(project_id)
|
||||
|
||||
assert factors == []
|
||||
|
||||
|
||||
class TestAnomalyDetection:
|
||||
"""Tests for anomaly detection."""
|
||||
|
||||
async def test_detect_duration_anomaly(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect unusual duration anomalies."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Create baseline with consistent durations
|
||||
now = datetime.now(UTC)
|
||||
baseline = [
|
||||
create_mock_episode(
|
||||
duration_seconds=60.0,
|
||||
occurred_at=now - timedelta(days=i),
|
||||
)
|
||||
for i in range(2, 10)
|
||||
]
|
||||
|
||||
# Add recent anomaly with very long duration
|
||||
anomalous = create_mock_episode(
|
||||
duration_seconds=300.0, # 5x longer
|
||||
occurred_at=now - timedelta(hours=1),
|
||||
)
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=[*baseline, anomalous])
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
anomalies = await reflection.detect_anomalies(project_id, baseline_days=30)
|
||||
|
||||
duration_anomalies = [
|
||||
a for a in anomalies if a.anomaly_type == AnomalyType.UNUSUAL_DURATION
|
||||
]
|
||||
assert len(duration_anomalies) >= 1
|
||||
|
||||
async def test_detect_unexpected_outcome_anomaly(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect unexpected outcome anomalies."""
|
||||
project_id = uuid4()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
# Create baseline with high success rate
|
||||
baseline = [
|
||||
create_mock_episode(
|
||||
task_type="reliable_task",
|
||||
outcome=Outcome.SUCCESS,
|
||||
occurred_at=now - timedelta(days=i),
|
||||
)
|
||||
for i in range(2, 10)
|
||||
]
|
||||
|
||||
# Add recent failure for usually successful task
|
||||
anomalous = create_mock_episode(
|
||||
task_type="reliable_task",
|
||||
outcome=Outcome.FAILURE,
|
||||
occurred_at=now - timedelta(hours=1),
|
||||
)
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=[*baseline, anomalous])
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
anomalies = await reflection.detect_anomalies(project_id, baseline_days=30)
|
||||
|
||||
outcome_anomalies = [
|
||||
a for a in anomalies if a.anomaly_type == AnomalyType.UNEXPECTED_OUTCOME
|
||||
]
|
||||
assert len(outcome_anomalies) >= 1
|
||||
|
||||
async def test_detect_token_usage_anomaly(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect unusual token usage."""
|
||||
project_id = uuid4()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
# Create baseline with consistent token usage
|
||||
baseline = [
|
||||
create_mock_episode(
|
||||
tokens_used=100,
|
||||
occurred_at=now - timedelta(days=i),
|
||||
)
|
||||
for i in range(2, 10)
|
||||
]
|
||||
|
||||
# Add recent anomaly with very high token usage
|
||||
anomalous = create_mock_episode(
|
||||
tokens_used=1000, # 10x higher
|
||||
occurred_at=now - timedelta(hours=1),
|
||||
)
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=[*baseline, anomalous])
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
anomalies = await reflection.detect_anomalies(project_id, baseline_days=30)
|
||||
|
||||
token_anomalies = [
|
||||
a for a in anomalies if a.anomaly_type == AnomalyType.UNUSUAL_TOKEN_USAGE
|
||||
]
|
||||
assert len(token_anomalies) >= 1
|
||||
|
||||
async def test_detect_failure_rate_spike(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should detect failure rate spikes."""
|
||||
project_id = uuid4()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
# Create baseline with low failure rate
|
||||
baseline = [
|
||||
create_mock_episode(
|
||||
outcome=Outcome.SUCCESS if i % 10 != 0 else Outcome.FAILURE,
|
||||
occurred_at=now - timedelta(days=i % 30),
|
||||
)
|
||||
for i in range(30)
|
||||
]
|
||||
|
||||
# Add recent failures (spike)
|
||||
recent_failures = [
|
||||
create_mock_episode(
|
||||
outcome=Outcome.FAILURE,
|
||||
occurred_at=now - timedelta(hours=i),
|
||||
)
|
||||
for i in range(1, 6)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=baseline + recent_failures)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
anomalies = await reflection.detect_anomalies(project_id, baseline_days=30)
|
||||
|
||||
# May or may not detect based on thresholds
|
||||
# Just verify the analysis completes without error
|
||||
assert isinstance(anomalies, list)
|
||||
|
||||
async def test_insufficient_baseline(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should return empty when insufficient baseline."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Only 3 episodes, config requires 5 minimum
|
||||
episodes = [create_mock_episode() for _ in range(3)]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
anomalies = await reflection.detect_anomalies(project_id, baseline_days=30)
|
||||
|
||||
assert anomalies == []
|
||||
|
||||
|
||||
class TestInsightGeneration:
|
||||
"""Tests for insight generation."""
|
||||
|
||||
async def test_generate_warning_insight_from_failure_pattern(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should generate warning insight from failure patterns."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Create episodes with recurring failure
|
||||
episodes = [
|
||||
create_mock_episode(task_type="failing_task", outcome=Outcome.FAILURE)
|
||||
for _ in range(8)
|
||||
] + [
|
||||
create_mock_episode(task_type="failing_task", outcome=Outcome.SUCCESS)
|
||||
for _ in range(2)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
insights = await reflection.generate_insights(project_id)
|
||||
|
||||
warning_insights = [
|
||||
i for i in insights if i.insight_type == InsightType.WARNING
|
||||
]
|
||||
assert len(warning_insights) >= 1
|
||||
|
||||
async def test_generate_learning_insight_from_success_pattern(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should generate learning insight from success patterns."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Create episodes with recurring success
|
||||
episodes = [
|
||||
create_mock_episode(task_type="good_task", outcome=Outcome.SUCCESS)
|
||||
for _ in range(9)
|
||||
] + [
|
||||
create_mock_episode(task_type="good_task", outcome=Outcome.FAILURE)
|
||||
for _ in range(1)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
insights = await reflection.generate_insights(project_id)
|
||||
|
||||
learning_insights = [
|
||||
i for i in insights if i.insight_type == InsightType.LEARNING
|
||||
]
|
||||
assert len(learning_insights) >= 0 # May depend on thresholds
|
||||
|
||||
async def test_generate_trend_insight(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should generate overall trend insight."""
|
||||
project_id = uuid4()
|
||||
|
||||
# Create enough episodes with timestamps in range
|
||||
now = datetime.now(UTC)
|
||||
episodes = [
|
||||
create_mock_episode(
|
||||
outcome=Outcome.SUCCESS,
|
||||
occurred_at=now - timedelta(hours=i),
|
||||
)
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
insights = await reflection.generate_insights(project_id)
|
||||
|
||||
trend_insights = [i for i in insights if i.insight_type == InsightType.TREND]
|
||||
assert len(trend_insights) >= 1
|
||||
|
||||
async def test_insights_sorted_by_priority(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should sort insights by priority."""
|
||||
project_id = uuid4()
|
||||
|
||||
episodes = [create_mock_episode(outcome=Outcome.SUCCESS) for _ in range(10)]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
insights = await reflection.generate_insights(project_id)
|
||||
|
||||
if len(insights) >= 2:
|
||||
for i in range(len(insights) - 1):
|
||||
assert insights[i].priority >= insights[i + 1].priority
|
||||
|
||||
|
||||
class TestComprehensiveReflection:
|
||||
"""Tests for comprehensive reflect() method."""
|
||||
|
||||
async def test_reflect_returns_all_components(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should return patterns, factors, anomalies, and insights."""
|
||||
project_id = uuid4()
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
episodes = [
|
||||
create_mock_episode(
|
||||
task_type="test_task",
|
||||
outcome=Outcome.SUCCESS if i % 2 == 0 else Outcome.FAILURE,
|
||||
occurred_at=now - timedelta(hours=i),
|
||||
)
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
result = await reflection.reflect(project_id, time_range)
|
||||
|
||||
assert result.patterns is not None
|
||||
assert result.factors is not None
|
||||
assert result.anomalies is not None
|
||||
assert result.insights is not None
|
||||
assert result.episodes_analyzed >= 0
|
||||
assert result.analysis_duration_seconds >= 0
|
||||
|
||||
async def test_reflect_with_default_time_range(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should use default 7-day time range."""
|
||||
project_id = uuid4()
|
||||
|
||||
episodes = [create_mock_episode() for _ in range(5)]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
result = await reflection.reflect(project_id)
|
||||
|
||||
assert 6.9 <= result.time_range.duration_days <= 7.1
|
||||
|
||||
async def test_reflect_summary(
|
||||
self,
|
||||
reflection: MemoryReflection,
|
||||
) -> None:
|
||||
"""Should generate meaningful summary."""
|
||||
project_id = uuid4()
|
||||
|
||||
episodes = [create_mock_episode() for _ in range(10)]
|
||||
|
||||
mock_episodic = MagicMock()
|
||||
mock_episodic.get_recent = AsyncMock(return_value=episodes)
|
||||
reflection._episodic = mock_episodic
|
||||
|
||||
result = await reflection.reflect(project_id)
|
||||
|
||||
summary = result.summary
|
||||
assert "Reflection Analysis" in summary
|
||||
assert "Episodes analyzed" in summary
|
||||
|
||||
|
||||
class TestFactoryFunction:
|
||||
"""Tests for factory function behavior.
|
||||
|
||||
Note: The singleton pattern was removed to avoid stale database session bugs.
|
||||
Each call now creates a fresh instance, which is safer for request-scoped usage.
|
||||
"""
|
||||
|
||||
async def test_get_memory_reflection_creates_new_instance(
|
||||
self,
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Should create new instance each call (no singleton for session safety)."""
|
||||
r1 = await get_memory_reflection(mock_session)
|
||||
r2 = await get_memory_reflection(mock_session)
|
||||
|
||||
# Different instances to avoid stale session issues
|
||||
assert r1 is not r2
|
||||
|
||||
async def test_reset_is_no_op(
|
||||
self,
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Reset should be a no-op (kept for API compatibility)."""
|
||||
r1 = await get_memory_reflection(mock_session)
|
||||
await reset_memory_reflection() # Should not raise
|
||||
r2 = await get_memory_reflection(mock_session)
|
||||
|
||||
# Still creates new instances (reset is no-op now)
|
||||
assert r1 is not r2
|
||||
559
backend/tests/unit/services/memory/reflection/test_types.py
Normal file
559
backend/tests/unit/services/memory/reflection/test_types.py
Normal file
@@ -0,0 +1,559 @@
|
||||
# tests/unit/services/memory/reflection/test_types.py
|
||||
"""Tests for Memory Reflection types."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.memory.reflection.types import (
|
||||
Anomaly,
|
||||
AnomalyType,
|
||||
Factor,
|
||||
FactorType,
|
||||
Insight,
|
||||
InsightType,
|
||||
Pattern,
|
||||
PatternType,
|
||||
ReflectionResult,
|
||||
TimeRange,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeRange:
|
||||
"""Tests for TimeRange."""
|
||||
|
||||
def test_creates_time_range(self) -> None:
|
||||
"""Should create time range with start and end."""
|
||||
start = datetime.now(UTC) - timedelta(days=7)
|
||||
end = datetime.now(UTC)
|
||||
|
||||
tr = TimeRange(start=start, end=end)
|
||||
|
||||
assert tr.start == start
|
||||
assert tr.end == end
|
||||
|
||||
def test_last_hours(self) -> None:
|
||||
"""Should create time range for last N hours."""
|
||||
tr = TimeRange.last_hours(24)
|
||||
|
||||
assert tr.duration_hours >= 23.9
|
||||
assert tr.duration_hours <= 24.1
|
||||
|
||||
def test_last_days(self) -> None:
|
||||
"""Should create time range for last N days."""
|
||||
tr = TimeRange.last_days(7)
|
||||
|
||||
assert tr.duration_days >= 6.9
|
||||
assert tr.duration_days <= 7.1
|
||||
|
||||
def test_duration_hours(self) -> None:
|
||||
"""Should calculate duration in hours."""
|
||||
start = datetime.now(UTC) - timedelta(hours=12)
|
||||
end = datetime.now(UTC)
|
||||
|
||||
tr = TimeRange(start=start, end=end)
|
||||
|
||||
assert 11.9 <= tr.duration_hours <= 12.1
|
||||
|
||||
def test_duration_days(self) -> None:
|
||||
"""Should calculate duration in days."""
|
||||
start = datetime.now(UTC) - timedelta(days=3)
|
||||
end = datetime.now(UTC)
|
||||
|
||||
tr = TimeRange(start=start, end=end)
|
||||
|
||||
assert 2.9 <= tr.duration_days <= 3.1
|
||||
|
||||
|
||||
class TestPattern:
|
||||
"""Tests for Pattern."""
|
||||
|
||||
def test_creates_pattern(self) -> None:
|
||||
"""Should create pattern with all fields."""
|
||||
now = datetime.now(UTC)
|
||||
episode_ids = [uuid4(), uuid4(), uuid4()]
|
||||
|
||||
pattern = Pattern(
|
||||
id=uuid4(),
|
||||
pattern_type=PatternType.RECURRING_SUCCESS,
|
||||
name="Test Pattern",
|
||||
description="A test pattern",
|
||||
confidence=0.85,
|
||||
occurrence_count=10,
|
||||
episode_ids=episode_ids,
|
||||
first_seen=now - timedelta(days=7),
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
assert pattern.name == "Test Pattern"
|
||||
assert pattern.confidence == 0.85
|
||||
assert len(pattern.episode_ids) == 3
|
||||
|
||||
def test_frequency_calculation(self) -> None:
|
||||
"""Should calculate frequency per day."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
pattern = Pattern(
|
||||
id=uuid4(),
|
||||
pattern_type=PatternType.RECURRING_SUCCESS,
|
||||
name="Test",
|
||||
description="Test",
|
||||
confidence=0.8,
|
||||
occurrence_count=14,
|
||||
episode_ids=[],
|
||||
first_seen=now - timedelta(days=7),
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
assert pattern.frequency == 2.0 # 14 occurrences / 7 days
|
||||
|
||||
def test_frequency_minimum_one_day(self) -> None:
|
||||
"""Should use minimum 1 day for frequency calculation."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
pattern = Pattern(
|
||||
id=uuid4(),
|
||||
pattern_type=PatternType.RECURRING_SUCCESS,
|
||||
name="Test",
|
||||
description="Test",
|
||||
confidence=0.8,
|
||||
occurrence_count=5,
|
||||
episode_ids=[],
|
||||
first_seen=now - timedelta(hours=1), # Less than 1 day
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
assert pattern.frequency == 5.0 # 5 / 1 day minimum
|
||||
|
||||
def test_to_dict(self) -> None:
|
||||
"""Should convert to dictionary."""
|
||||
pattern = Pattern(
|
||||
id=uuid4(),
|
||||
pattern_type=PatternType.ACTION_SEQUENCE,
|
||||
name="Action Pattern",
|
||||
description="Action sequence",
|
||||
confidence=0.75,
|
||||
occurrence_count=5,
|
||||
episode_ids=[uuid4()],
|
||||
first_seen=datetime.now(UTC) - timedelta(days=1),
|
||||
last_seen=datetime.now(UTC),
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
result = pattern.to_dict()
|
||||
|
||||
assert result["name"] == "Action Pattern"
|
||||
assert result["pattern_type"] == "action_sequence"
|
||||
assert result["confidence"] == 0.75
|
||||
assert "frequency" in result
|
||||
assert result["metadata"] == {"key": "value"}
|
||||
|
||||
|
||||
class TestFactor:
|
||||
"""Tests for Factor."""
|
||||
|
||||
def test_creates_factor(self) -> None:
|
||||
"""Should create factor with all fields."""
|
||||
factor = Factor(
|
||||
id=uuid4(),
|
||||
factor_type=FactorType.ACTION,
|
||||
name="Test Factor",
|
||||
description="A test factor",
|
||||
impact_score=0.7,
|
||||
correlation=0.5,
|
||||
sample_size=20,
|
||||
positive_examples=[uuid4()],
|
||||
negative_examples=[uuid4()],
|
||||
)
|
||||
|
||||
assert factor.name == "Test Factor"
|
||||
assert factor.impact_score == 0.7
|
||||
assert factor.correlation == 0.5
|
||||
|
||||
def test_net_impact_calculation(self) -> None:
|
||||
"""Should calculate net impact."""
|
||||
factor = Factor(
|
||||
id=uuid4(),
|
||||
factor_type=FactorType.CONTEXT,
|
||||
name="Test",
|
||||
description="Test",
|
||||
impact_score=0.8,
|
||||
correlation=0.6,
|
||||
sample_size=20,
|
||||
positive_examples=[],
|
||||
negative_examples=[],
|
||||
)
|
||||
|
||||
# net_impact = impact_score * correlation * confidence_weight
|
||||
# confidence_weight = min(1.0, 20/20) = 1.0
|
||||
expected = 0.8 * 0.6 * 1.0
|
||||
assert factor.net_impact == expected
|
||||
|
||||
def test_net_impact_with_small_sample(self) -> None:
|
||||
"""Should weight net impact by sample size."""
|
||||
factor = Factor(
|
||||
id=uuid4(),
|
||||
factor_type=FactorType.CONTEXT,
|
||||
name="Test",
|
||||
description="Test",
|
||||
impact_score=0.8,
|
||||
correlation=0.6,
|
||||
sample_size=10, # Half of 20
|
||||
positive_examples=[],
|
||||
negative_examples=[],
|
||||
)
|
||||
|
||||
# confidence_weight = min(1.0, 10/20) = 0.5
|
||||
expected = 0.8 * 0.6 * 0.5
|
||||
assert factor.net_impact == expected
|
||||
|
||||
def test_to_dict(self) -> None:
|
||||
"""Should convert to dictionary."""
|
||||
factor = Factor(
|
||||
id=uuid4(),
|
||||
factor_type=FactorType.TIMING,
|
||||
name="Timing Factor",
|
||||
description="Time-related",
|
||||
impact_score=0.6,
|
||||
correlation=-0.3,
|
||||
sample_size=15,
|
||||
positive_examples=[],
|
||||
negative_examples=[],
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
result = factor.to_dict()
|
||||
|
||||
assert result["name"] == "Timing Factor"
|
||||
assert result["factor_type"] == "timing"
|
||||
assert "net_impact" in result
|
||||
assert result["metadata"] == {"key": "value"}
|
||||
|
||||
|
||||
class TestAnomaly:
|
||||
"""Tests for Anomaly."""
|
||||
|
||||
def test_creates_anomaly(self) -> None:
|
||||
"""Should create anomaly with all fields."""
|
||||
anomaly = Anomaly(
|
||||
id=uuid4(),
|
||||
anomaly_type=AnomalyType.UNUSUAL_DURATION,
|
||||
description="Unusual duration detected",
|
||||
severity=0.75,
|
||||
episode_ids=[uuid4()],
|
||||
detected_at=datetime.now(UTC),
|
||||
baseline_value=10.0,
|
||||
observed_value=30.0,
|
||||
deviation_factor=3.0,
|
||||
)
|
||||
|
||||
assert anomaly.severity == 0.75
|
||||
assert anomaly.baseline_value == 10.0
|
||||
assert anomaly.deviation_factor == 3.0
|
||||
|
||||
def test_is_critical_high_severity(self) -> None:
|
||||
"""Should be critical when severity > 0.8."""
|
||||
anomaly = Anomaly(
|
||||
id=uuid4(),
|
||||
anomaly_type=AnomalyType.UNUSUAL_FAILURE_RATE,
|
||||
description="High failure rate",
|
||||
severity=0.9,
|
||||
episode_ids=[],
|
||||
detected_at=datetime.now(UTC),
|
||||
baseline_value=0.1,
|
||||
observed_value=0.5,
|
||||
deviation_factor=5.0,
|
||||
)
|
||||
|
||||
assert anomaly.is_critical is True
|
||||
|
||||
def test_is_critical_low_severity(self) -> None:
|
||||
"""Should not be critical when severity <= 0.8."""
|
||||
anomaly = Anomaly(
|
||||
id=uuid4(),
|
||||
anomaly_type=AnomalyType.UNUSUAL_DURATION,
|
||||
description="Slightly unusual",
|
||||
severity=0.6,
|
||||
episode_ids=[],
|
||||
detected_at=datetime.now(UTC),
|
||||
baseline_value=10.0,
|
||||
observed_value=20.0,
|
||||
deviation_factor=2.0,
|
||||
)
|
||||
|
||||
assert anomaly.is_critical is False
|
||||
|
||||
def test_to_dict(self) -> None:
|
||||
"""Should convert to dictionary."""
|
||||
anomaly = Anomaly(
|
||||
id=uuid4(),
|
||||
anomaly_type=AnomalyType.UNEXPECTED_OUTCOME,
|
||||
description="Unexpected failure",
|
||||
severity=0.85,
|
||||
episode_ids=[uuid4()],
|
||||
detected_at=datetime.now(UTC),
|
||||
baseline_value=0.9,
|
||||
observed_value=0.0,
|
||||
deviation_factor=0.9,
|
||||
metadata={"task_type": "test"},
|
||||
)
|
||||
|
||||
result = anomaly.to_dict()
|
||||
|
||||
assert result["anomaly_type"] == "unexpected_outcome"
|
||||
assert result["severity"] == 0.85
|
||||
assert result["is_critical"] is True
|
||||
assert result["metadata"] == {"task_type": "test"}
|
||||
|
||||
|
||||
class TestInsight:
|
||||
"""Tests for Insight."""
|
||||
|
||||
def test_creates_insight(self) -> None:
|
||||
"""Should create insight with all fields."""
|
||||
insight = Insight(
|
||||
id=uuid4(),
|
||||
insight_type=InsightType.OPTIMIZATION,
|
||||
title="Performance Opportunity",
|
||||
description="Optimization potential found",
|
||||
priority=0.8,
|
||||
confidence=0.75,
|
||||
source_patterns=[uuid4()],
|
||||
source_factors=[],
|
||||
source_anomalies=[],
|
||||
recommended_actions=["Action 1", "Action 2"],
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
assert insight.title == "Performance Opportunity"
|
||||
assert insight.priority == 0.8
|
||||
assert len(insight.recommended_actions) == 2
|
||||
|
||||
def test_actionable_score(self) -> None:
|
||||
"""Should calculate actionable score."""
|
||||
insight = Insight(
|
||||
id=uuid4(),
|
||||
insight_type=InsightType.RECOMMENDATION,
|
||||
title="Test",
|
||||
description="Test",
|
||||
priority=0.8,
|
||||
confidence=0.9,
|
||||
source_patterns=[],
|
||||
source_factors=[],
|
||||
source_anomalies=[],
|
||||
recommended_actions=["Action 1", "Action 2", "Action 3"],
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# actionable_score = priority * confidence * action_weight
|
||||
# action_weight = min(1.0, 3/3) = 1.0
|
||||
expected = 0.8 * 0.9 * 1.0
|
||||
assert insight.actionable_score == expected
|
||||
|
||||
def test_actionable_score_few_actions(self) -> None:
|
||||
"""Should weight by action count."""
|
||||
insight = Insight(
|
||||
id=uuid4(),
|
||||
insight_type=InsightType.WARNING,
|
||||
title="Test",
|
||||
description="Test",
|
||||
priority=0.8,
|
||||
confidence=0.9,
|
||||
source_patterns=[],
|
||||
source_factors=[],
|
||||
source_anomalies=[],
|
||||
recommended_actions=["Action 1"], # Only 1 action
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# action_weight = min(1.0, 1/3) = 0.333...
|
||||
expected = 0.8 * 0.9 * (1 / 3)
|
||||
assert abs(insight.actionable_score - expected) < 0.001
|
||||
|
||||
def test_to_dict(self) -> None:
|
||||
"""Should convert to dictionary."""
|
||||
insight = Insight(
|
||||
id=uuid4(),
|
||||
insight_type=InsightType.TREND,
|
||||
title="Trend Analysis",
|
||||
description="Performance trend",
|
||||
priority=0.6,
|
||||
confidence=0.7,
|
||||
source_patterns=[uuid4()],
|
||||
source_factors=[uuid4()],
|
||||
source_anomalies=[],
|
||||
recommended_actions=["Monitor", "Review"],
|
||||
generated_at=datetime.now(UTC),
|
||||
metadata={"health_score": 0.85},
|
||||
)
|
||||
|
||||
result = insight.to_dict()
|
||||
|
||||
assert result["insight_type"] == "trend"
|
||||
assert result["title"] == "Trend Analysis"
|
||||
assert "actionable_score" in result
|
||||
assert result["metadata"] == {"health_score": 0.85}
|
||||
|
||||
|
||||
class TestReflectionResult:
|
||||
"""Tests for ReflectionResult."""
|
||||
|
||||
def test_creates_result(self) -> None:
|
||||
"""Should create reflection result."""
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
result = ReflectionResult(
|
||||
patterns=[],
|
||||
factors=[],
|
||||
anomalies=[],
|
||||
insights=[],
|
||||
time_range=time_range,
|
||||
episodes_analyzed=100,
|
||||
analysis_duration_seconds=2.5,
|
||||
)
|
||||
|
||||
assert result.episodes_analyzed == 100
|
||||
assert result.analysis_duration_seconds == 2.5
|
||||
|
||||
def test_to_dict(self) -> None:
|
||||
"""Should convert to dictionary."""
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
result = ReflectionResult(
|
||||
patterns=[
|
||||
Pattern(
|
||||
id=uuid4(),
|
||||
pattern_type=PatternType.RECURRING_SUCCESS,
|
||||
name="Test",
|
||||
description="Test",
|
||||
confidence=0.8,
|
||||
occurrence_count=5,
|
||||
episode_ids=[],
|
||||
first_seen=datetime.now(UTC),
|
||||
last_seen=datetime.now(UTC),
|
||||
)
|
||||
],
|
||||
factors=[],
|
||||
anomalies=[],
|
||||
insights=[],
|
||||
time_range=time_range,
|
||||
episodes_analyzed=50,
|
||||
analysis_duration_seconds=1.5,
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
|
||||
assert len(data["patterns"]) == 1
|
||||
assert data["episodes_analyzed"] == 50
|
||||
assert "time_range" in data
|
||||
assert "duration_hours" in data["time_range"]
|
||||
|
||||
def test_summary(self) -> None:
|
||||
"""Should generate summary text."""
|
||||
time_range = TimeRange.last_days(7)
|
||||
|
||||
result = ReflectionResult(
|
||||
patterns=[
|
||||
Pattern(
|
||||
id=uuid4(),
|
||||
pattern_type=PatternType.RECURRING_SUCCESS,
|
||||
name="Pattern 1",
|
||||
description="Test",
|
||||
confidence=0.8,
|
||||
occurrence_count=5,
|
||||
episode_ids=[],
|
||||
first_seen=datetime.now(UTC),
|
||||
last_seen=datetime.now(UTC),
|
||||
)
|
||||
],
|
||||
factors=[
|
||||
Factor(
|
||||
id=uuid4(),
|
||||
factor_type=FactorType.ACTION,
|
||||
name="Factor 1",
|
||||
description="Test",
|
||||
impact_score=0.6,
|
||||
correlation=0.4,
|
||||
sample_size=10,
|
||||
positive_examples=[],
|
||||
negative_examples=[],
|
||||
)
|
||||
],
|
||||
anomalies=[],
|
||||
insights=[
|
||||
Insight(
|
||||
id=uuid4(),
|
||||
insight_type=InsightType.OPTIMIZATION,
|
||||
title="Top Insight",
|
||||
description="Test",
|
||||
priority=0.9,
|
||||
confidence=0.8,
|
||||
source_patterns=[],
|
||||
source_factors=[],
|
||||
source_anomalies=[],
|
||||
recommended_actions=["Action"],
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
],
|
||||
time_range=time_range,
|
||||
episodes_analyzed=100,
|
||||
analysis_duration_seconds=2.0,
|
||||
)
|
||||
|
||||
summary = result.summary
|
||||
|
||||
assert "Reflection Analysis" in summary
|
||||
assert "Episodes analyzed: 100" in summary
|
||||
assert "Patterns detected: 1" in summary
|
||||
assert "Success/failure factors: 1" in summary
|
||||
assert "Insights generated: 1" in summary
|
||||
assert "Top insights:" in summary
|
||||
assert "Top Insight" in summary
|
||||
|
||||
|
||||
class TestPatternType:
|
||||
"""Tests for PatternType enum."""
|
||||
|
||||
def test_all_pattern_types(self) -> None:
|
||||
"""Should have all expected pattern types."""
|
||||
assert PatternType.RECURRING_SUCCESS.value == "recurring_success"
|
||||
assert PatternType.RECURRING_FAILURE.value == "recurring_failure"
|
||||
assert PatternType.ACTION_SEQUENCE.value == "action_sequence"
|
||||
assert PatternType.CONTEXT_CORRELATION.value == "context_correlation"
|
||||
assert PatternType.TEMPORAL.value == "temporal"
|
||||
assert PatternType.EFFICIENCY.value == "efficiency"
|
||||
|
||||
|
||||
class TestFactorType:
|
||||
"""Tests for FactorType enum."""
|
||||
|
||||
def test_all_factor_types(self) -> None:
|
||||
"""Should have all expected factor types."""
|
||||
assert FactorType.ACTION.value == "action"
|
||||
assert FactorType.CONTEXT.value == "context"
|
||||
assert FactorType.TIMING.value == "timing"
|
||||
assert FactorType.RESOURCE.value == "resource"
|
||||
assert FactorType.PRECEDING_STATE.value == "preceding_state"
|
||||
|
||||
|
||||
class TestAnomalyType:
|
||||
"""Tests for AnomalyType enum."""
|
||||
|
||||
def test_all_anomaly_types(self) -> None:
|
||||
"""Should have all expected anomaly types."""
|
||||
assert AnomalyType.UNUSUAL_DURATION.value == "unusual_duration"
|
||||
assert AnomalyType.UNEXPECTED_OUTCOME.value == "unexpected_outcome"
|
||||
assert AnomalyType.UNUSUAL_TOKEN_USAGE.value == "unusual_token_usage"
|
||||
assert AnomalyType.UNUSUAL_FAILURE_RATE.value == "unusual_failure_rate"
|
||||
assert AnomalyType.UNUSUAL_ACTION_PATTERN.value == "unusual_action_pattern"
|
||||
|
||||
|
||||
class TestInsightType:
|
||||
"""Tests for InsightType enum."""
|
||||
|
||||
def test_all_insight_types(self) -> None:
|
||||
"""Should have all expected insight types."""
|
||||
assert InsightType.OPTIMIZATION.value == "optimization"
|
||||
assert InsightType.WARNING.value == "warning"
|
||||
assert InsightType.LEARNING.value == "learning"
|
||||
assert InsightType.RECOMMENDATION.value == "recommendation"
|
||||
assert InsightType.TREND.value == "trend"
|
||||
@@ -2,7 +2,7 @@
|
||||
Tests for Memory System Types.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.memory.types import (
|
||||
@@ -150,7 +150,7 @@ class TestMemoryItem:
|
||||
|
||||
def test_get_age_seconds(self) -> None:
|
||||
"""Test getting item age."""
|
||||
past = datetime.now() - timedelta(seconds=100)
|
||||
past = datetime.now(UTC) - timedelta(seconds=100)
|
||||
item = MemoryItem(
|
||||
id=uuid4(),
|
||||
memory_type=MemoryType.SEMANTIC,
|
||||
@@ -202,7 +202,7 @@ class TestWorkingMemoryItem:
|
||||
scope_id="sess-123",
|
||||
key="my_key",
|
||||
value="value",
|
||||
expires_at=datetime.now() + timedelta(hours=1),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert item.is_expired() is False
|
||||
@@ -215,7 +215,7 @@ class TestWorkingMemoryItem:
|
||||
scope_id="sess-123",
|
||||
key="my_key",
|
||||
value="value",
|
||||
expires_at=datetime.now() - timedelta(hours=1),
|
||||
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert item.is_expired() is True
|
||||
|
||||
@@ -276,7 +276,7 @@ class TestWorkingMemoryCheckpoints:
|
||||
checkpoint_id = await memory.create_checkpoint("Test checkpoint")
|
||||
|
||||
assert checkpoint_id is not None
|
||||
assert len(checkpoint_id) == 8 # UUID prefix
|
||||
assert len(checkpoint_id) == 36 # Full UUID for collision safety
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_checkpoint(self, memory: WorkingMemory) -> None:
|
||||
|
||||
@@ -78,13 +78,13 @@ class TestInMemoryStorageTTL:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_expiration(self, storage: InMemoryStorage) -> None:
|
||||
"""Test that expired keys return None."""
|
||||
await storage.set("key1", "value1", ttl_seconds=1)
|
||||
await storage.set("key1", "value1", ttl_seconds=0.1)
|
||||
|
||||
# Key exists initially
|
||||
assert await storage.get("key1") == "value1"
|
||||
|
||||
# Wait for expiration
|
||||
await asyncio.sleep(1.1)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Key should be expired
|
||||
assert await storage.get("key1") is None
|
||||
@@ -93,10 +93,10 @@ class TestInMemoryStorageTTL:
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_ttl_on_update(self, storage: InMemoryStorage) -> None:
|
||||
"""Test that updating without TTL removes expiration."""
|
||||
await storage.set("key1", "value1", ttl_seconds=1)
|
||||
await storage.set("key1", "value1", ttl_seconds=0.1)
|
||||
await storage.set("key1", "value2") # No TTL
|
||||
|
||||
await asyncio.sleep(1.1)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Key should still exist (TTL removed)
|
||||
assert await storage.get("key1") == "value2"
|
||||
@@ -180,10 +180,10 @@ class TestInMemoryStorageCapacity:
|
||||
"""Test that expired keys are cleaned up for capacity."""
|
||||
storage = InMemoryStorage(max_keys=2)
|
||||
|
||||
await storage.set("key1", "value1", ttl_seconds=1)
|
||||
await storage.set("key1", "value1", ttl_seconds=0.1)
|
||||
await storage.set("key2", "value2")
|
||||
|
||||
await asyncio.sleep(1.1)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Should succeed because key1 is expired and will be cleaned
|
||||
await storage.set("key3", "value3")
|
||||
|
||||
@@ -288,6 +288,7 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- NEXT_PUBLIC_API_BASE_URL=http://backend:8000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -96,6 +96,38 @@ services:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
mcp-git-ops:
|
||||
build:
|
||||
context: ./mcp-servers/git-ops
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8003:8003"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# GIT_OPS_ prefix required by pydantic-settings config
|
||||
- GIT_OPS_HOST=0.0.0.0
|
||||
- GIT_OPS_PORT=8003
|
||||
- GIT_OPS_REDIS_URL=redis://redis:6379/3
|
||||
- GIT_OPS_GITEA_BASE_URL=${GITEA_BASE_URL}
|
||||
- GIT_OPS_GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GIT_OPS_GITHUB_TOKEN=${GITHUB_TOKEN}
|
||||
- ENVIRONMENT=development
|
||||
volumes:
|
||||
- git_workspaces_dev:/workspaces
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8003/health').raise_for_status()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -119,6 +151,7 @@ services:
|
||||
# MCP Server URLs
|
||||
- LLM_GATEWAY_URL=http://mcp-llm-gateway:8001
|
||||
- KNOWLEDGE_BASE_URL=http://mcp-knowledge-base:8002
|
||||
- GIT_OPS_URL=http://mcp-git-ops:8003
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -128,6 +161,8 @@ services:
|
||||
condition: service_healthy
|
||||
mcp-knowledge-base:
|
||||
condition: service_healthy
|
||||
mcp-git-ops:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
@@ -155,6 +190,7 @@ services:
|
||||
# MCP Server URLs (agents need access to MCP)
|
||||
- LLM_GATEWAY_URL=http://mcp-llm-gateway:8001
|
||||
- KNOWLEDGE_BASE_URL=http://mcp-knowledge-base:8002
|
||||
- GIT_OPS_URL=http://mcp-git-ops:8003
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -164,6 +200,8 @@ services:
|
||||
condition: service_healthy
|
||||
mcp-knowledge-base:
|
||||
condition: service_healthy
|
||||
mcp-git-ops:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
command: ["celery", "-A", "app.celery_app", "worker", "-Q", "agent", "-l", "info", "-c", "4"]
|
||||
@@ -181,11 +219,14 @@ services:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_QUEUE=git
|
||||
- GIT_OPS_URL=http://mcp-git-ops:8003
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mcp-git-ops:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
command: ["celery", "-A", "app.celery_app", "worker", "-Q", "git", "-l", "info", "-c", "2"]
|
||||
@@ -249,6 +290,7 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- NEXT_PUBLIC_API_BASE_URL=http://backend:8000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -259,6 +301,7 @@ services:
|
||||
volumes:
|
||||
postgres_data_dev:
|
||||
redis_data_dev:
|
||||
git_workspaces_dev:
|
||||
frontend_dev_modules:
|
||||
frontend_dev_next:
|
||||
|
||||
|
||||
@@ -74,12 +74,14 @@ const nextConfig: NextConfig = {
|
||||
];
|
||||
},
|
||||
|
||||
// Ensure we can connect to the backend in Docker
|
||||
// Proxy API requests to backend
|
||||
// Use NEXT_PUBLIC_API_BASE_URL for the destination (defaults to localhost for local dev)
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://backend:8000/:path*',
|
||||
destination: `${backendUrl}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
55
frontend/package-lock.json
generated
55
frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.1",
|
||||
@@ -4688,6 +4689,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
|
||||
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.1",
|
||||
|
||||
@@ -73,6 +73,13 @@ export default function AgentTypeDetailPage() {
|
||||
mcp_servers: data.mcp_servers,
|
||||
tool_permissions: data.tool_permissions,
|
||||
is_active: data.is_active,
|
||||
// Category and display fields
|
||||
category: data.category,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order,
|
||||
typical_tasks: data.typical_tasks,
|
||||
collaboration_hints: data.collaboration_hints,
|
||||
});
|
||||
toast.success('Agent type created', {
|
||||
description: `${result.name} has been created successfully`,
|
||||
@@ -94,6 +101,13 @@ export default function AgentTypeDetailPage() {
|
||||
mcp_servers: data.mcp_servers,
|
||||
tool_permissions: data.tool_permissions,
|
||||
is_active: data.is_active,
|
||||
// Category and display fields
|
||||
category: data.category,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
sort_order: data.sort_order,
|
||||
typical_tasks: data.typical_tasks,
|
||||
collaboration_hints: data.collaboration_hints,
|
||||
},
|
||||
});
|
||||
toast.success('Agent type updated', {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Agent Types List Page
|
||||
*
|
||||
* Displays a list of agent types with search and filter functionality.
|
||||
* Allows navigation to agent type detail and creation pages.
|
||||
* Displays a list of agent types with search, status, and category filters.
|
||||
* Supports grid and list view modes with user preference persistence.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -10,9 +10,10 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from '@/lib/i18n/routing';
|
||||
import { toast } from 'sonner';
|
||||
import { AgentTypeList } from '@/components/agents';
|
||||
import { AgentTypeList, type ViewMode } from '@/components/agents';
|
||||
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';
|
||||
import { useDebounce } from '@/lib/hooks/useDebounce';
|
||||
import type { AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||
|
||||
export default function AgentTypesPage() {
|
||||
const router = useRouter();
|
||||
@@ -20,6 +21,8 @@ export default function AgentTypesPage() {
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
|
||||
// Debounce search for API calls
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
@@ -31,21 +34,25 @@ export default function AgentTypesPage() {
|
||||
return undefined; // 'all' returns undefined to not filter
|
||||
}, [statusFilter]);
|
||||
|
||||
// Determine category filter value
|
||||
const categoryFilterValue = useMemo(() => {
|
||||
if (categoryFilter === 'all') return undefined;
|
||||
return categoryFilter as AgentTypeCategory;
|
||||
}, [categoryFilter]);
|
||||
|
||||
// Fetch agent types
|
||||
const { data, isLoading, error } = useAgentTypes({
|
||||
search: debouncedSearch || undefined,
|
||||
is_active: isActiveFilter,
|
||||
category: categoryFilterValue,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
// Filter results client-side for 'all' status
|
||||
// Get filtered and sorted agent types (sort by sort_order ascending - smaller first)
|
||||
const filteredAgentTypes = useMemo(() => {
|
||||
if (!data?.data) return [];
|
||||
|
||||
// When status is 'all', we need to fetch both and combine
|
||||
// For now, the API returns based on is_active filter
|
||||
return data.data;
|
||||
return [...data.data].sort((a, b) => a.sort_order - b.sort_order);
|
||||
}, [data?.data]);
|
||||
|
||||
// Handle navigation to agent type detail
|
||||
@@ -71,6 +78,16 @@ export default function AgentTypesPage() {
|
||||
setStatusFilter(status);
|
||||
}, []);
|
||||
|
||||
// Handle category filter change
|
||||
const handleCategoryFilterChange = useCallback((category: string) => {
|
||||
setCategoryFilter(category);
|
||||
}, []);
|
||||
|
||||
// Handle view mode change
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
}, []);
|
||||
|
||||
// Show error toast if fetch fails
|
||||
if (error) {
|
||||
toast.error('Failed to load agent types', {
|
||||
@@ -87,6 +104,10 @@ export default function AgentTypesPage() {
|
||||
onSearchChange={handleSearchChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={handleStatusFilterChange}
|
||||
categoryFilter={categoryFilter}
|
||||
onCategoryFilterChange={handleCategoryFilterChange}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onSelect={handleSelect}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* AgentTypeDetail Component
|
||||
*
|
||||
* Displays detailed information about a single agent type.
|
||||
* Shows model configuration, permissions, personality, and instance stats.
|
||||
* Features a hero header with icon/color, category, typical tasks,
|
||||
* collaboration hints, model configuration, and instance stats.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -36,8 +37,13 @@ import {
|
||||
Cpu,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Users,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
import { DynamicIcon } from '@/components/ui/dynamic-icon';
|
||||
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
|
||||
import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType';
|
||||
|
||||
interface AgentTypeDetailProps {
|
||||
@@ -51,6 +57,30 @@ interface AgentTypeDetailProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category badge with color
|
||||
*/
|
||||
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
|
||||
if (!category) return null;
|
||||
|
||||
const meta = CATEGORY_METADATA[category];
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-medium"
|
||||
style={{
|
||||
borderColor: meta.color,
|
||||
color: meta.color,
|
||||
backgroundColor: `${meta.color}10`,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge component for agent types
|
||||
*/
|
||||
@@ -81,11 +111,22 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
|
||||
function AgentTypeDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-2 h-4 w-48" />
|
||||
{/* Hero skeleton */}
|
||||
<div className="rounded-xl border p-6">
|
||||
<div className="flex items-start gap-6">
|
||||
<Skeleton className="h-20 w-20 rounded-xl" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
@@ -161,57 +202,134 @@ export function AgentTypeDetail({
|
||||
top_p?: number;
|
||||
};
|
||||
|
||||
const agentColor = agentType.color || '#3B82F6';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Go back</span>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{agentType.name}</h1>
|
||||
<AgentTypeStatusBadge isActive={agentType.is_active} />
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Agent Types
|
||||
</Button>
|
||||
|
||||
{/* Hero Header */}
|
||||
<div
|
||||
className="mb-6 overflow-hidden rounded-xl border"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${agentColor}08 0%, transparent 60%)`,
|
||||
borderColor: `${agentColor}30`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-1.5 w-full"
|
||||
style={{ background: `linear-gradient(90deg, ${agentColor}, ${agentColor}60)` }}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-start">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
backgroundColor: `${agentColor}15`,
|
||||
boxShadow: `0 8px 32px ${agentColor}20`,
|
||||
}}
|
||||
>
|
||||
<DynamicIcon
|
||||
name={agentType.icon}
|
||||
className="h-10 w-10"
|
||||
style={{ color: agentColor }}
|
||||
fallback="bot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{agentType.name}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{agentType.description || 'No description provided'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AgentTypeStatusBadge isActive={agentType.is_active} />
|
||||
<CategoryBadge category={agentType.category} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Last updated:{' '}
|
||||
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onDuplicate}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Last modified:{' '}
|
||||
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onDuplicate}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Description Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Description
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{agentType.description || 'No description provided'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* What This Agent Does Best */}
|
||||
{agentType.typical_tasks.length > 0 && (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
What This Agent Does Best
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{agentType.typical_tasks.map((task, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<Check
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||
style={{ color: agentColor }}
|
||||
/>
|
||||
<span className="text-sm">{task}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Works Well With */}
|
||||
{agentType.collaboration_hints.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Users className="h-5 w-5" />
|
||||
Works Well With
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Agents that complement this type for effective collaboration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agentType.collaboration_hints.map((hint, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-sm">
|
||||
{hint}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Expertise Card */}
|
||||
<Card>
|
||||
@@ -355,7 +473,9 @@ export function AgentTypeDetail({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-primary">{agentType.instance_count}</p>
|
||||
<p className="text-4xl font-bold" style={{ color: agentColor }}>
|
||||
{agentType.instance_count}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Active instances</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-4 w-full" size="sm" disabled>
|
||||
@@ -364,6 +484,36 @@ export function AgentTypeDetail({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5" />
|
||||
Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Slug</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{agentType.slug}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sort Order</span>
|
||||
<span>{agentType.sort_order}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>
|
||||
{new Date(agentType.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
*
|
||||
* React Hook Form-based form for creating and editing agent types.
|
||||
* Features tabbed interface for organizing form sections.
|
||||
*
|
||||
* Uses reusable form utilities for:
|
||||
* - Validation error handling with toast notifications
|
||||
* - Safe API-to-form data transformation with defaults
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -32,19 +36,89 @@ import {
|
||||
type AgentTypeCreateFormValues,
|
||||
AVAILABLE_MODELS,
|
||||
AVAILABLE_MCP_SERVERS,
|
||||
AGENT_TYPE_CATEGORIES,
|
||||
defaultAgentTypeValues,
|
||||
generateSlug,
|
||||
} from '@/lib/validations/agentType';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
import { useValidationErrorHandler, deepMergeWithDefaults, isNumber } from '@/lib/forms';
|
||||
|
||||
interface AgentTypeFormProps {
|
||||
agentType?: AgentTypeResponse;
|
||||
onSubmit: (data: AgentTypeCreateFormValues) => void;
|
||||
onSubmit: (data: AgentTypeCreateFormValues) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
isSubmitting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Tab navigation mapping for validation errors
|
||||
const TAB_FIELD_MAPPING = {
|
||||
name: 'basic',
|
||||
slug: 'basic',
|
||||
description: 'basic',
|
||||
expertise: 'basic',
|
||||
is_active: 'basic',
|
||||
// Category and display fields
|
||||
category: 'basic',
|
||||
icon: 'basic',
|
||||
color: 'basic',
|
||||
sort_order: 'basic',
|
||||
typical_tasks: 'basic',
|
||||
collaboration_hints: 'basic',
|
||||
primary_model: 'model',
|
||||
fallback_models: 'model',
|
||||
model_params: 'model',
|
||||
mcp_servers: 'permissions',
|
||||
tool_permissions: 'permissions',
|
||||
personality_prompt: 'personality',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Transform API response to form values with safe defaults
|
||||
*
|
||||
* Uses deepMergeWithDefaults for most fields, with special handling
|
||||
* for model_params which needs numeric type validation.
|
||||
*/
|
||||
function transformAgentTypeToFormValues(
|
||||
agentType: AgentTypeResponse | undefined
|
||||
): AgentTypeCreateFormValues {
|
||||
if (!agentType) return defaultAgentTypeValues;
|
||||
|
||||
// model_params needs special handling for numeric validation
|
||||
const modelParams = agentType.model_params ?? {};
|
||||
const safeModelParams = {
|
||||
temperature: isNumber(modelParams.temperature) ? modelParams.temperature : 0.7,
|
||||
max_tokens: isNumber(modelParams.max_tokens) ? modelParams.max_tokens : 8192,
|
||||
top_p: isNumber(modelParams.top_p) ? modelParams.top_p : 0.95,
|
||||
};
|
||||
|
||||
// Merge with defaults, then override model_params with safe version
|
||||
const merged = deepMergeWithDefaults(defaultAgentTypeValues, {
|
||||
name: agentType.name,
|
||||
slug: agentType.slug,
|
||||
description: agentType.description,
|
||||
expertise: agentType.expertise,
|
||||
personality_prompt: agentType.personality_prompt,
|
||||
primary_model: agentType.primary_model,
|
||||
fallback_models: agentType.fallback_models,
|
||||
mcp_servers: agentType.mcp_servers,
|
||||
tool_permissions: agentType.tool_permissions,
|
||||
is_active: agentType.is_active,
|
||||
// Category and display fields
|
||||
category: agentType.category,
|
||||
icon: agentType.icon,
|
||||
color: agentType.color,
|
||||
sort_order: agentType.sort_order ?? 0,
|
||||
typical_tasks: agentType.typical_tasks ?? [],
|
||||
collaboration_hints: agentType.collaboration_hints ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
...merged,
|
||||
model_params: safeModelParams,
|
||||
};
|
||||
}
|
||||
|
||||
export function AgentTypeForm({
|
||||
agentType,
|
||||
onSubmit,
|
||||
@@ -55,29 +129,16 @@ export function AgentTypeForm({
|
||||
const isEditing = !!agentType;
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [expertiseInput, setExpertiseInput] = useState('');
|
||||
const [typicalTaskInput, setTypicalTaskInput] = useState('');
|
||||
const [collaborationHintInput, setCollaborationHintInput] = useState('');
|
||||
|
||||
// Memoize initial values transformation
|
||||
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
|
||||
|
||||
// Always use create schema for validation - editing requires all fields too
|
||||
const form = useForm<AgentTypeCreateFormValues>({
|
||||
resolver: zodResolver(agentTypeCreateSchema),
|
||||
defaultValues: agentType
|
||||
? {
|
||||
name: agentType.name,
|
||||
slug: agentType.slug,
|
||||
description: agentType.description,
|
||||
expertise: agentType.expertise,
|
||||
personality_prompt: agentType.personality_prompt,
|
||||
primary_model: agentType.primary_model,
|
||||
fallback_models: agentType.fallback_models,
|
||||
model_params: (agentType.model_params ?? {
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
top_p: 0.95,
|
||||
}) as AgentTypeCreateFormValues['model_params'],
|
||||
mcp_servers: agentType.mcp_servers,
|
||||
tool_permissions: agentType.tool_permissions,
|
||||
is_active: agentType.is_active,
|
||||
}
|
||||
: defaultAgentTypeValues,
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -89,11 +150,28 @@ export function AgentTypeForm({
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
// Use the reusable validation error handler hook
|
||||
const { onValidationError } = useValidationErrorHandler<AgentTypeCreateFormValues>({
|
||||
tabMapping: TAB_FIELD_MAPPING,
|
||||
setActiveTab,
|
||||
});
|
||||
|
||||
const watchName = watch('name');
|
||||
/* istanbul ignore next -- defensive fallback, expertise always has default */
|
||||
const watchExpertise = watch('expertise') || [];
|
||||
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
|
||||
const watchMcpServers = watch('mcp_servers') || [];
|
||||
/* istanbul ignore next -- defensive fallback, typical_tasks always has default */
|
||||
const watchTypicalTasks = watch('typical_tasks') || [];
|
||||
/* istanbul ignore next -- defensive fallback, collaboration_hints always has default */
|
||||
const watchCollaborationHints = watch('collaboration_hints') || [];
|
||||
|
||||
// Reset form when agentType changes (e.g., switching to edit mode)
|
||||
useEffect(() => {
|
||||
if (agentType) {
|
||||
form.reset(initialValues);
|
||||
}
|
||||
}, [agentType?.id, form, initialValues]);
|
||||
|
||||
// Auto-generate slug from name for new agent types
|
||||
useEffect(() => {
|
||||
@@ -132,8 +210,50 @@ export function AgentTypeForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTypicalTask = () => {
|
||||
if (typicalTaskInput.trim()) {
|
||||
const newTask = typicalTaskInput.trim();
|
||||
if (!watchTypicalTasks.includes(newTask)) {
|
||||
setValue('typical_tasks', [...watchTypicalTasks, newTask]);
|
||||
}
|
||||
setTypicalTaskInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTypicalTask = (task: string) => {
|
||||
setValue(
|
||||
'typical_tasks',
|
||||
watchTypicalTasks.filter((t) => t !== task)
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddCollaborationHint = () => {
|
||||
if (collaborationHintInput.trim()) {
|
||||
const newHint = collaborationHintInput.trim().toLowerCase();
|
||||
if (!watchCollaborationHints.includes(newHint)) {
|
||||
setValue('collaboration_hints', [...watchCollaborationHints, newHint]);
|
||||
}
|
||||
setCollaborationHintInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCollaborationHint = (hint: string) => {
|
||||
setValue(
|
||||
'collaboration_hints',
|
||||
watchCollaborationHints.filter((h) => h !== hint)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle form submission with validation
|
||||
const onFormSubmit = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
return handleSubmit(onSubmit, onValidationError)(e);
|
||||
},
|
||||
[handleSubmit, onSubmit, onValidationError]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={className}>
|
||||
<form onSubmit={onFormSubmit} className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCancel}>
|
||||
@@ -311,6 +431,188 @@ export function AgentTypeForm({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category & Display Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category & Display</CardTitle>
|
||||
<CardDescription>
|
||||
Organize and customize how this agent type appears in the UI
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ?? ''}
|
||||
onValueChange={(val) => field.onChange(val || null)}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENT_TYPE_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Group agents by their primary role
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">Sort Order</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
{...register('sort_order', { valueAsNumber: true })}
|
||||
aria-invalid={!!errors.sort_order}
|
||||
/>
|
||||
{errors.sort_order && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{errors.sort_order.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Display order within category</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">Icon</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
placeholder="e.g., git-branch"
|
||||
{...register('icon')}
|
||||
aria-invalid={!!errors.icon}
|
||||
/>
|
||||
{errors.icon && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{errors.icon.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Lucide icon name for UI display</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color">Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color"
|
||||
placeholder="#3B82F6"
|
||||
{...register('color')}
|
||||
aria-invalid={!!errors.color}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="color"
|
||||
value={field.value ?? '#3B82F6'}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
className="h-9 w-9 cursor-pointer rounded border"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{errors.color && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{errors.color.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Hex color for visual distinction</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Typical Tasks</Label>
|
||||
<p className="text-sm text-muted-foreground">Tasks this agent type excels at</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g., Design system architecture"
|
||||
value={typicalTaskInput}
|
||||
onChange={(e) => setTypicalTaskInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTypicalTask();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleAddTypicalTask}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{watchTypicalTasks.map((task) => (
|
||||
<Badge key={task} variant="secondary" className="gap-1">
|
||||
{task}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded-full hover:bg-muted"
|
||||
onClick={() => handleRemoveTypicalTask(task)}
|
||||
aria-label={`Remove ${task}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Collaboration Hints</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agent slugs that work well with this type
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g., backend-engineer"
|
||||
value={collaborationHintInput}
|
||||
onChange={(e) => setCollaborationHintInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCollaborationHint();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleAddCollaborationHint}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{watchCollaborationHints.map((hint) => (
|
||||
<Badge key={hint} variant="outline" className="gap-1">
|
||||
{hint}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded-full hover:bg-muted"
|
||||
onClick={() => handleRemoveCollaborationHint(hint)}
|
||||
aria-label={`Remove ${hint}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Configuration Tab */}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* AgentTypeList Component
|
||||
*
|
||||
* Displays a grid of agent type cards with search and filter functionality.
|
||||
* Used on the main agent types page for browsing and selecting agent types.
|
||||
* Displays agent types in grid or list view with search, status, and category filters.
|
||||
* Shows icon, color accent, and category for each agent type.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -20,8 +20,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Bot, Plus, Search, Cpu } from 'lucide-react';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Bot, Plus, Search, Cpu, LayoutGrid, List } from 'lucide-react';
|
||||
import { DynamicIcon } from '@/components/ui/dynamic-icon';
|
||||
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
|
||||
import { AGENT_TYPE_CATEGORIES } from '@/lib/validations/agentType';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
interface AgentTypeListProps {
|
||||
agentTypes: AgentTypeResponse[];
|
||||
@@ -30,6 +36,10 @@ interface AgentTypeListProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
statusFilter: string;
|
||||
onStatusFilterChange: (status: string) => void;
|
||||
categoryFilter: string;
|
||||
onCategoryFilterChange: (category: string) => void;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
className?: string;
|
||||
@@ -60,11 +70,36 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for agent type cards
|
||||
* Category badge with color
|
||||
*/
|
||||
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
|
||||
if (!category) return null;
|
||||
|
||||
const meta = CATEGORY_METADATA[category];
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-medium"
|
||||
style={{
|
||||
borderColor: meta.color,
|
||||
color: meta.color,
|
||||
backgroundColor: `${meta.color}10`,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for agent type cards (grid view)
|
||||
*/
|
||||
function AgentTypeCardSkeleton() {
|
||||
return (
|
||||
<Card className="h-[200px]">
|
||||
<Card className="h-[220px] overflow-hidden">
|
||||
<div className="h-1 w-full bg-muted" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
@@ -91,6 +126,23 @@ function AgentTypeCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for list view
|
||||
*/
|
||||
function AgentTypeListSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-lg border p-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-20" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model display name from model ID
|
||||
*/
|
||||
@@ -103,6 +155,169 @@ function getModelDisplayName(modelId: string): string {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid card view for agent type
|
||||
*/
|
||||
function AgentTypeGridCard({
|
||||
type,
|
||||
onSelect,
|
||||
}: {
|
||||
type: AgentTypeResponse;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const agentColor = type.color || '#3B82F6';
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer overflow-hidden transition-all hover:shadow-lg"
|
||||
onClick={() => onSelect(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(type.id);
|
||||
}
|
||||
}}
|
||||
aria-label={`View ${type.name} agent type`}
|
||||
style={{
|
||||
borderTopColor: agentColor,
|
||||
borderTopWidth: '3px',
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className="flex h-11 w-11 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
backgroundColor: `${agentColor}15`,
|
||||
}}
|
||||
>
|
||||
<DynamicIcon
|
||||
name={type.icon}
|
||||
className="h-5 w-5"
|
||||
style={{ color: agentColor }}
|
||||
fallback="bot"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<AgentTypeStatusBadge isActive={type.is_active} />
|
||||
<CategoryBadge category={type.category} />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="mt-3 line-clamp-1">{type.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{type.description || 'No description provided'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Expertise tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{type.expertise.slice(0, 3).map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="text-xs">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
{type.expertise.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{type.expertise.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
{type.expertise.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">No expertise defined</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{type.instance_count} instances</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List row view for agent type
|
||||
*/
|
||||
function AgentTypeListRow({
|
||||
type,
|
||||
onSelect,
|
||||
}: {
|
||||
type: AgentTypeResponse;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const agentColor = type.color || '#3B82F6';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg border p-4 transition-all hover:border-primary hover:shadow-md"
|
||||
onClick={() => onSelect(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(type.id);
|
||||
}
|
||||
}}
|
||||
aria-label={`View ${type.name} agent type`}
|
||||
style={{
|
||||
borderLeftColor: agentColor,
|
||||
borderLeftWidth: '4px',
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: `${agentColor}15` }}
|
||||
>
|
||||
<DynamicIcon
|
||||
name={type.icon}
|
||||
className="h-6 w-6"
|
||||
style={{ color: agentColor }}
|
||||
fallback="bot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{type.name}</h3>
|
||||
<CategoryBadge category={type.category} />
|
||||
</div>
|
||||
<p className="line-clamp-1 text-sm text-muted-foreground">
|
||||
{type.description || 'No description'}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{getModelDisplayName(type.primary_model)}
|
||||
</span>
|
||||
<span>{type.expertise.length} expertise areas</span>
|
||||
<span>{type.instance_count} instances</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="shrink-0">
|
||||
<AgentTypeStatusBadge isActive={type.is_active} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTypeList({
|
||||
agentTypes,
|
||||
isLoading = false,
|
||||
@@ -110,6 +325,10 @@ export function AgentTypeList({
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
categoryFilter,
|
||||
onCategoryFilterChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
className,
|
||||
@@ -131,7 +350,7 @@ export function AgentTypeList({
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -142,8 +361,25 @@ export function AgentTypeList({
|
||||
aria-label="Search agent types"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
|
||||
<SelectTrigger className="w-full sm:w-44" aria-label="Filter by category">
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{AGENT_TYPE_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
|
||||
<SelectTrigger className="w-full sm:w-36" aria-label="Filter by status">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -152,10 +388,25 @@ export function AgentTypeList({
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
onValueChange={(value: string) => value && onViewModeChange(value as ViewMode)}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ToggleGroupItem value="grid" aria-label="Grid view" size="sm">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="list" aria-label="List view" size="sm">
|
||||
<List className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
{/* Loading State - Grid */}
|
||||
{isLoading && viewMode === 'grid' && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<AgentTypeCardSkeleton key={i} />
|
||||
@@ -163,71 +414,29 @@ export function AgentTypeList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Type Grid */}
|
||||
{!isLoading && agentTypes.length > 0 && (
|
||||
{/* Loading State - List */}
|
||||
{isLoading && viewMode === 'list' && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<AgentTypeListSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Type Grid View */}
|
||||
{!isLoading && agentTypes.length > 0 && viewMode === 'grid' && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{agentTypes.map((type) => (
|
||||
<Card
|
||||
key={type.id}
|
||||
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
|
||||
onClick={() => onSelect(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(type.id);
|
||||
}
|
||||
}}
|
||||
aria-label={`View ${type.name} agent type`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<AgentTypeStatusBadge isActive={type.is_active} />
|
||||
</div>
|
||||
<CardTitle className="mt-3">{type.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{type.description || 'No description provided'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Expertise tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{type.expertise.slice(0, 3).map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="text-xs">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
{type.expertise.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{type.expertise.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
{type.expertise.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">No expertise defined</span>
|
||||
)}
|
||||
</div>
|
||||
<AgentTypeGridCard key={type.id} type={type} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{type.instance_count} instances</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Agent Type List View */}
|
||||
{!isLoading && agentTypes.length > 0 && viewMode === 'list' && (
|
||||
<div className="space-y-3">
|
||||
{agentTypes.map((type) => (
|
||||
<AgentTypeListRow key={type.id} type={type} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -238,11 +447,11 @@ export function AgentTypeList({
|
||||
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 font-semibold">No agent types found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
{searchQuery || statusFilter !== 'all' || categoryFilter !== 'all'
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Create your first agent type to get started'}
|
||||
</p>
|
||||
{!searchQuery && statusFilter === 'all' && (
|
||||
{!searchQuery && statusFilter === 'all' && categoryFilter === 'all' && (
|
||||
<Button onClick={onCreate} className="mt-4">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Agent Type
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
*/
|
||||
|
||||
export { AgentTypeForm } from './AgentTypeForm';
|
||||
export { AgentTypeList } from './AgentTypeList';
|
||||
export { AgentTypeList, type ViewMode } from './AgentTypeList';
|
||||
export { AgentTypeDetail } from './AgentTypeDetail';
|
||||
|
||||
@@ -31,8 +31,6 @@ import { PendingApprovals } from './PendingApprovals';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useDashboard, type PendingApproval } from '@/lib/api/hooks/useDashboard';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useProjectEvents } from '@/lib/hooks/useProjectEvents';
|
||||
import { useProjectEventsFromStore } from '@/lib/stores/eventStore';
|
||||
|
||||
export interface DashboardProps {
|
||||
/** Additional CSS classes */
|
||||
@@ -43,13 +41,6 @@ export function Dashboard({ className }: DashboardProps) {
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, error } = useDashboard();
|
||||
|
||||
// Real-time events - using a generic project ID for dashboard-wide events
|
||||
// In production, this would be a dedicated dashboard events endpoint
|
||||
const { connectionState } = useProjectEvents('dashboard', {
|
||||
autoConnect: true,
|
||||
});
|
||||
const events = useProjectEventsFromStore('dashboard');
|
||||
|
||||
// Get user's first name for empty state
|
||||
const firstName = user?.first_name || user?.email?.split('@')[0] || 'there';
|
||||
|
||||
@@ -108,11 +99,13 @@ export function Dashboard({ className }: DashboardProps) {
|
||||
</div>
|
||||
|
||||
{/* Activity Feed Sidebar */}
|
||||
{/* TODO: Enable when global activity SSE endpoint is implemented */}
|
||||
{/* Currently disabled - there's no dashboard-wide SSE endpoint */}
|
||||
<div className="hidden lg:block">
|
||||
<Card className="sticky top-4">
|
||||
<ActivityFeed
|
||||
events={events}
|
||||
connectionState={connectionState}
|
||||
events={[]}
|
||||
connectionState="disconnected"
|
||||
isLoading={isLoading}
|
||||
maxHeight={600}
|
||||
showHeader
|
||||
|
||||
133
frontend/src/components/forms/FormSelect.tsx
Normal file
133
frontend/src/components/forms/FormSelect.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FormSelect Component
|
||||
*
|
||||
* Reusable Select field with Controller integration for react-hook-form.
|
||||
* Handles label, error display, and description automatically.
|
||||
*
|
||||
* @module components/forms/FormSelect
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Controller, type Control, type FieldValues, type Path } from 'react-hook-form';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FormSelectProps<T extends FieldValues> {
|
||||
/** Field name (must be a valid path in the form) */
|
||||
name: Path<T>;
|
||||
/** Form control from useForm */
|
||||
control: Control<T>;
|
||||
/** Field label */
|
||||
label: string;
|
||||
/** Available options */
|
||||
options: SelectOption[];
|
||||
/** Is field required? Shows asterisk if true */
|
||||
required?: boolean;
|
||||
/** Placeholder text when no value selected */
|
||||
placeholder?: string;
|
||||
/** Helper text below the field */
|
||||
description?: string;
|
||||
/** Disable the select */
|
||||
disabled?: boolean;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSelect - Controlled Select field for react-hook-form
|
||||
*
|
||||
* Automatically handles:
|
||||
* - Controller wrapper for react-hook-form
|
||||
* - Label with required indicator
|
||||
* - Error message display
|
||||
* - Description/helper text
|
||||
* - Accessibility attributes
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormSelect
|
||||
* name="primary_model"
|
||||
* control={form.control}
|
||||
* label="Primary Model"
|
||||
* required
|
||||
* options={[
|
||||
* { value: 'claude-opus', label: 'Claude Opus' },
|
||||
* { value: 'claude-sonnet', label: 'Claude Sonnet' },
|
||||
* ]}
|
||||
* description="Main model used for this agent"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FormSelect<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
label,
|
||||
options,
|
||||
required = false,
|
||||
placeholder,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
}: FormSelectProps<T>) {
|
||||
const selectId = String(name);
|
||||
const errorId = `${selectId}-error`;
|
||||
const descriptionId = description ? `${selectId}-description` : undefined;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div className={className}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={selectId}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
id={selectId}
|
||||
aria-invalid={!!fieldState.error}
|
||||
aria-describedby={
|
||||
[fieldState.error ? errorId : null, descriptionId].filter(Boolean).join(' ') ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={placeholder ?? `Select ${label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.error && (
|
||||
<p id={errorId} className="text-sm text-destructive" role="alert">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p id={descriptionId} className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/forms/FormTextarea.tsx
Normal file
101
frontend/src/components/forms/FormTextarea.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* FormTextarea Component
|
||||
*
|
||||
* Reusable Textarea field for react-hook-form with register integration.
|
||||
* Handles label, error display, and description automatically.
|
||||
*
|
||||
* @module components/forms/FormTextarea
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ComponentProps } from 'react';
|
||||
import type { FieldError, UseFormRegisterReturn } from 'react-hook-form';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export interface FormTextareaProps extends Omit<ComponentProps<typeof Textarea>, 'children'> {
|
||||
/** Field label */
|
||||
label: string;
|
||||
/** Field name (optional if provided via register) */
|
||||
name?: string;
|
||||
/** Is field required? Shows asterisk if true */
|
||||
required?: boolean;
|
||||
/** Form error from react-hook-form */
|
||||
error?: FieldError;
|
||||
/** Helper text below the field */
|
||||
description?: string;
|
||||
/** Register return object from useForm */
|
||||
registration?: UseFormRegisterReturn;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormTextarea - Textarea field for react-hook-form
|
||||
*
|
||||
* Automatically handles:
|
||||
* - Label with required indicator
|
||||
* - Error message display
|
||||
* - Description/helper text
|
||||
* - Accessibility attributes
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FormTextarea
|
||||
* label="Personality Prompt"
|
||||
* required
|
||||
* error={errors.personality_prompt}
|
||||
* rows={10}
|
||||
* {...register('personality_prompt')}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FormTextarea({
|
||||
label,
|
||||
name: explicitName,
|
||||
required = false,
|
||||
error,
|
||||
description,
|
||||
registration,
|
||||
...textareaProps
|
||||
}: FormTextareaProps) {
|
||||
// Extract name from props or registration
|
||||
const registerName =
|
||||
'name' in textareaProps ? (textareaProps as { name: string }).name : undefined;
|
||||
const name = explicitName || registerName || registration?.name;
|
||||
|
||||
if (!name) {
|
||||
throw new Error('FormTextarea: name must be provided either explicitly or via register()');
|
||||
}
|
||||
|
||||
const errorId = error ? `${name}-error` : undefined;
|
||||
const descriptionId = description ? `${name}-description` : undefined;
|
||||
const ariaDescribedBy = [errorId, descriptionId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
// Merge registration props with other props
|
||||
const mergedProps = registration ? { ...registration, ...textareaProps } : textareaProps;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{description && (
|
||||
<p id={descriptionId} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<Textarea
|
||||
id={name}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
{...mergedProps}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
// Shared form components and utilities
|
||||
export { FormField } from './FormField';
|
||||
export type { FormFieldProps } from './FormField';
|
||||
export { FormSelect } from './FormSelect';
|
||||
export type { FormSelectProps, SelectOption } from './FormSelect';
|
||||
export { FormTextarea } from './FormTextarea';
|
||||
export type { FormTextareaProps } from './FormTextarea';
|
||||
export { useFormError } from './useFormError';
|
||||
export type { UseFormErrorReturn } from './useFormError';
|
||||
|
||||
84
frontend/src/components/ui/dynamic-icon.tsx
Normal file
84
frontend/src/components/ui/dynamic-icon.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* DynamicIcon Component
|
||||
*
|
||||
* Renders Lucide icons dynamically by name string.
|
||||
* Useful when icon names come from data (e.g., database).
|
||||
*/
|
||||
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Map of icon names to their components.
|
||||
* Uses kebab-case names (e.g., 'clipboard-check') as keys.
|
||||
*/
|
||||
const iconMap: Record<string, React.ComponentType<LucideProps>> = {
|
||||
// Development
|
||||
'clipboard-check': LucideIcons.ClipboardCheck,
|
||||
briefcase: LucideIcons.Briefcase,
|
||||
'file-text': LucideIcons.FileText,
|
||||
'git-branch': LucideIcons.GitBranch,
|
||||
code: LucideIcons.Code,
|
||||
server: LucideIcons.Server,
|
||||
layout: LucideIcons.Layout,
|
||||
smartphone: LucideIcons.Smartphone,
|
||||
// Design
|
||||
palette: LucideIcons.Palette,
|
||||
search: LucideIcons.Search,
|
||||
// Quality
|
||||
shield: LucideIcons.Shield,
|
||||
'shield-check': LucideIcons.ShieldCheck,
|
||||
// Operations
|
||||
settings: LucideIcons.Settings,
|
||||
'settings-2': LucideIcons.Settings2,
|
||||
// AI/ML
|
||||
brain: LucideIcons.Brain,
|
||||
microscope: LucideIcons.Microscope,
|
||||
eye: LucideIcons.Eye,
|
||||
'message-square': LucideIcons.MessageSquare,
|
||||
// Data
|
||||
'bar-chart': LucideIcons.BarChart,
|
||||
database: LucideIcons.Database,
|
||||
// Leadership
|
||||
users: LucideIcons.Users,
|
||||
target: LucideIcons.Target,
|
||||
// Domain Expert
|
||||
calculator: LucideIcons.Calculator,
|
||||
'heart-pulse': LucideIcons.HeartPulse,
|
||||
'flask-conical': LucideIcons.FlaskConical,
|
||||
lightbulb: LucideIcons.Lightbulb,
|
||||
'book-open': LucideIcons.BookOpen,
|
||||
// Generic
|
||||
bot: LucideIcons.Bot,
|
||||
cpu: LucideIcons.Cpu,
|
||||
};
|
||||
|
||||
interface DynamicIconProps extends Omit<LucideProps, 'name'> {
|
||||
/** Icon name in kebab-case (e.g., 'clipboard-check', 'bot') */
|
||||
name: string | null | undefined;
|
||||
/** Fallback icon name if the specified icon is not found */
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Lucide icon dynamically by name.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DynamicIcon name="clipboard-check" className="h-5 w-5" />
|
||||
* <DynamicIcon name={agent.icon} fallback="bot" />
|
||||
* ```
|
||||
*/
|
||||
export function DynamicIcon({ name, fallback = 'bot', ...props }: DynamicIconProps) {
|
||||
const iconName = name || fallback;
|
||||
const IconComponent = iconMap[iconName] || iconMap[fallback] || LucideIcons.Bot;
|
||||
|
||||
return <IconComponent {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available icon names for validation or display
|
||||
*/
|
||||
export function getAvailableIconNames(): string[] {
|
||||
return Object.keys(iconMap);
|
||||
}
|
||||
93
frontend/src/components/ui/toggle-group.tsx
Normal file
93
frontend/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleGroupVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border bg-transparent',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border border-input',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'outline',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const toggleGroupItemVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent hover:bg-muted hover:text-muted-foreground',
|
||||
outline: 'bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-3',
|
||||
sm: 'h-9 px-2.5',
|
||||
lg: 'h-11 px-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleGroupItemVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleGroupVariants> &
|
||||
VariantProps<typeof toggleGroupItemVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleGroupVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleGroupItemVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleGroupItemVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -44,10 +44,10 @@ const DEFAULT_PAGE_LIMIT = 20;
|
||||
export function useAgentTypes(params: AgentTypeListParams = {}) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search } = params;
|
||||
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search, category } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.list({ page, limit, is_active, search }),
|
||||
queryKey: agentTypeKeys.list({ page, limit, is_active, search, category }),
|
||||
queryFn: async (): Promise<AgentTypeListResponse> => {
|
||||
const response = await apiClient.instance.get('/api/v1/agent-types', {
|
||||
params: {
|
||||
@@ -55,6 +55,7 @@ export function useAgentTypes(params: AgentTypeListParams = {}) {
|
||||
limit,
|
||||
is_active,
|
||||
...(search ? { search } : {}),
|
||||
...(category ? { category } : {}),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
* - Recent projects
|
||||
* - Pending approvals
|
||||
*
|
||||
* Uses mock data until backend endpoints are available.
|
||||
* Fetches real data from the API.
|
||||
*
|
||||
* @see Issue #53
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { Project, ProjectStatus } from '@/components/projects/types';
|
||||
import { listProjects as listProjectsApi } from '@/lib/api/generated';
|
||||
import type { ProjectResponse } from '@/lib/api/generated';
|
||||
import type { AutonomyLevel, Project, ProjectStatus } from '@/components/projects/types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -52,118 +54,70 @@ export interface DashboardData {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const mockStats: DashboardStats = {
|
||||
activeProjects: 3,
|
||||
runningAgents: 8,
|
||||
openIssues: 24,
|
||||
pendingApprovals: 2,
|
||||
};
|
||||
/**
|
||||
* Format a date string as relative time (e.g., "2 minutes ago")
|
||||
*/
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
|
||||
const mockProjects: DashboardProject[] = [
|
||||
{
|
||||
id: 'proj-001',
|
||||
name: 'E-Commerce Platform Redesign',
|
||||
description: 'Complete redesign of the e-commerce platform with modern UI/UX',
|
||||
status: 'active' as ProjectStatus,
|
||||
autonomy_level: 'milestone',
|
||||
created_at: '2025-11-15T10:00:00Z',
|
||||
updated_at: '2025-12-30T14:30:00Z',
|
||||
owner_id: 'user-001',
|
||||
progress: 67,
|
||||
openIssues: 12,
|
||||
activeAgents: 4,
|
||||
currentSprint: 'Sprint 3',
|
||||
lastActivity: '2 minutes ago',
|
||||
},
|
||||
{
|
||||
id: 'proj-002',
|
||||
name: 'Mobile Banking App',
|
||||
description: 'Native mobile app for banking services with biometric authentication',
|
||||
status: 'active' as ProjectStatus,
|
||||
autonomy_level: 'autonomous',
|
||||
created_at: '2025-11-20T09:00:00Z',
|
||||
updated_at: '2025-12-30T12:00:00Z',
|
||||
owner_id: 'user-001',
|
||||
progress: 45,
|
||||
openIssues: 8,
|
||||
activeAgents: 5,
|
||||
currentSprint: 'Sprint 2',
|
||||
lastActivity: '15 minutes ago',
|
||||
},
|
||||
{
|
||||
id: 'proj-003',
|
||||
name: 'Internal HR Portal',
|
||||
description: 'Employee self-service portal for HR operations',
|
||||
status: 'paused' as ProjectStatus,
|
||||
autonomy_level: 'full_control',
|
||||
created_at: '2025-10-01T08:00:00Z',
|
||||
updated_at: '2025-12-28T16:00:00Z',
|
||||
owner_id: 'user-001',
|
||||
progress: 23,
|
||||
openIssues: 5,
|
||||
activeAgents: 0,
|
||||
currentSprint: 'Sprint 1',
|
||||
lastActivity: '2 days ago',
|
||||
},
|
||||
{
|
||||
id: 'proj-004',
|
||||
name: 'API Gateway Modernization',
|
||||
description: 'Migrate legacy API gateway to cloud-native architecture',
|
||||
status: 'active' as ProjectStatus,
|
||||
autonomy_level: 'milestone',
|
||||
created_at: '2025-12-01T11:00:00Z',
|
||||
updated_at: '2025-12-30T10:00:00Z',
|
||||
owner_id: 'user-001',
|
||||
progress: 82,
|
||||
openIssues: 3,
|
||||
activeAgents: 2,
|
||||
currentSprint: 'Sprint 4',
|
||||
lastActivity: '1 hour ago',
|
||||
},
|
||||
{
|
||||
id: 'proj-005',
|
||||
name: 'Customer Analytics Dashboard',
|
||||
description: 'Real-time analytics dashboard for customer behavior insights',
|
||||
status: 'completed' as ProjectStatus,
|
||||
autonomy_level: 'autonomous',
|
||||
created_at: '2025-09-01T10:00:00Z',
|
||||
updated_at: '2025-12-15T17:00:00Z',
|
||||
owner_id: 'user-001',
|
||||
progress: 100,
|
||||
openIssues: 0,
|
||||
activeAgents: 0,
|
||||
lastActivity: '2 weeks ago',
|
||||
},
|
||||
{
|
||||
id: 'proj-006',
|
||||
name: 'DevOps Pipeline Automation',
|
||||
description: 'Automate CI/CD pipelines with AI-assisted deployments',
|
||||
status: 'active' as ProjectStatus,
|
||||
autonomy_level: 'milestone',
|
||||
created_at: '2025-12-10T14:00:00Z',
|
||||
updated_at: '2025-12-30T09:00:00Z',
|
||||
owner_id: 'user-001',
|
||||
progress: 35,
|
||||
openIssues: 6,
|
||||
activeAgents: 3,
|
||||
currentSprint: 'Sprint 1',
|
||||
lastActivity: '30 minutes ago',
|
||||
},
|
||||
];
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks > 1 ? 's' : ''} ago`;
|
||||
return `${diffMonths} month${diffMonths > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps API ProjectResponse to DashboardProject format
|
||||
*/
|
||||
function mapToDashboardProject(
|
||||
project: ProjectResponse & Record<string, unknown>
|
||||
): DashboardProject {
|
||||
const updatedAt = project.updated_at || project.created_at || new Date().toISOString();
|
||||
const createdAt = project.created_at || new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description || undefined,
|
||||
status: project.status as ProjectStatus,
|
||||
autonomy_level: (project.autonomy_level || 'milestone') as AutonomyLevel,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
owner_id: project.owner_id || 'unknown',
|
||||
progress: (project.progress as number) || 0,
|
||||
openIssues: (project.openIssues as number) || project.issue_count || 0,
|
||||
activeAgents: (project.activeAgents as number) || project.agent_count || 0,
|
||||
currentSprint: project.active_sprint_name || undefined,
|
||||
lastActivity: formatRelativeTime(updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data (for pending approvals - no backend endpoint yet)
|
||||
// ============================================================================
|
||||
|
||||
const mockApprovals: PendingApproval[] = [
|
||||
{
|
||||
id: 'approval-001',
|
||||
type: 'sprint_boundary',
|
||||
title: 'Sprint 3 Completion Review',
|
||||
description: 'Review sprint deliverables and approve transition to Sprint 4',
|
||||
title: 'Sprint 1 Completion Review',
|
||||
description: 'Review sprint deliverables and approve transition to Sprint 2',
|
||||
projectId: 'proj-001',
|
||||
projectName: 'E-Commerce Platform Redesign',
|
||||
requestedBy: 'Product Owner Agent',
|
||||
requestedAt: '2025-12-30T14:00:00Z',
|
||||
requestedAt: new Date().toISOString(),
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
@@ -171,10 +125,10 @@ const mockApprovals: PendingApproval[] = [
|
||||
type: 'architecture_decision',
|
||||
title: 'Database Migration Strategy',
|
||||
description: 'Approve PostgreSQL to CockroachDB migration plan',
|
||||
projectId: 'proj-004',
|
||||
projectName: 'API Gateway Modernization',
|
||||
projectId: 'proj-002',
|
||||
projectName: 'Mobile Banking App',
|
||||
requestedBy: 'Architect Agent',
|
||||
requestedAt: '2025-12-30T10:30:00Z',
|
||||
requestedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
priority: 'medium',
|
||||
},
|
||||
];
|
||||
@@ -192,17 +146,41 @@ export function useDashboard() {
|
||||
return useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// Fetch real projects from API
|
||||
const response = await listProjectsApi({
|
||||
query: {
|
||||
limit: 6,
|
||||
},
|
||||
});
|
||||
|
||||
// Return mock data
|
||||
// TODO: Replace with actual API call when backend is ready
|
||||
// const response = await apiClient.get('/api/v1/dashboard');
|
||||
// return response.data;
|
||||
if (response.error) {
|
||||
throw new Error('Failed to fetch dashboard data');
|
||||
}
|
||||
|
||||
const projects = response.data.data.map((p) =>
|
||||
mapToDashboardProject(p as ProjectResponse & Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Sort by updated_at (most recent first)
|
||||
projects.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at || b.created_at).getTime() -
|
||||
new Date(a.updated_at || a.created_at).getTime()
|
||||
);
|
||||
|
||||
// Calculate stats from real data
|
||||
const activeProjects = projects.filter((p) => p.status === 'active').length;
|
||||
const runningAgents = projects.reduce((sum, p) => sum + p.activeAgents, 0);
|
||||
const openIssues = projects.reduce((sum, p) => sum + p.openIssues, 0);
|
||||
|
||||
return {
|
||||
stats: mockStats,
|
||||
recentProjects: mockProjects,
|
||||
stats: {
|
||||
activeProjects,
|
||||
runningAgents,
|
||||
openIssues,
|
||||
pendingApprovals: mockApprovals.length,
|
||||
},
|
||||
recentProjects: projects,
|
||||
pendingApprovals: mockApprovals,
|
||||
};
|
||||
},
|
||||
@@ -218,8 +196,24 @@ export function useDashboardStats() {
|
||||
return useQuery<DashboardStats>({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return mockStats;
|
||||
const response = await listProjectsApi({
|
||||
query: { limit: 100 },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error('Failed to fetch stats');
|
||||
}
|
||||
|
||||
const projects = response.data.data.map((p) =>
|
||||
mapToDashboardProject(p as ProjectResponse & Record<string, unknown>)
|
||||
);
|
||||
|
||||
return {
|
||||
activeProjects: projects.filter((p) => p.status === 'active').length,
|
||||
runningAgents: projects.reduce((sum, p) => sum + p.activeAgents, 0),
|
||||
openIssues: projects.reduce((sum, p) => sum + p.openIssues, 0),
|
||||
pendingApprovals: mockApprovals.length,
|
||||
};
|
||||
},
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
@@ -235,8 +229,26 @@ export function useRecentProjects(limit: number = 6) {
|
||||
return useQuery<DashboardProject[]>({
|
||||
queryKey: ['dashboard', 'recentProjects', limit],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
return mockProjects.slice(0, limit);
|
||||
const response = await listProjectsApi({
|
||||
query: { limit },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error('Failed to fetch recent projects');
|
||||
}
|
||||
|
||||
const projects = response.data.data.map((p) =>
|
||||
mapToDashboardProject(p as ProjectResponse & Record<string, unknown>)
|
||||
);
|
||||
|
||||
// Sort by updated_at (most recent first)
|
||||
projects.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at || b.created_at).getTime() -
|
||||
new Date(a.updated_at || a.created_at).getTime()
|
||||
);
|
||||
|
||||
return projects;
|
||||
},
|
||||
staleTime: 30000,
|
||||
});
|
||||
@@ -249,7 +261,7 @@ export function usePendingApprovals() {
|
||||
return useQuery<PendingApproval[]>({
|
||||
queryKey: ['dashboard', 'pendingApprovals'],
|
||||
queryFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
// TODO: Fetch from real API when endpoint exists
|
||||
return mockApprovals;
|
||||
},
|
||||
staleTime: 30000,
|
||||
|
||||
@@ -5,6 +5,68 @@
|
||||
* Used for type-safe API communication with the agent-types endpoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Category classification for agent types
|
||||
*/
|
||||
export type AgentTypeCategory =
|
||||
| 'development'
|
||||
| 'design'
|
||||
| 'quality'
|
||||
| 'operations'
|
||||
| 'ai_ml'
|
||||
| 'data'
|
||||
| 'leadership'
|
||||
| 'domain_expert';
|
||||
|
||||
/**
|
||||
* Metadata for each category including display label and description
|
||||
*/
|
||||
export const CATEGORY_METADATA: Record<
|
||||
AgentTypeCategory,
|
||||
{ label: string; description: string; color: string }
|
||||
> = {
|
||||
development: {
|
||||
label: 'Development',
|
||||
description: 'Product, project, and engineering roles',
|
||||
color: '#3B82F6',
|
||||
},
|
||||
design: {
|
||||
label: 'Design',
|
||||
description: 'UI/UX and design research',
|
||||
color: '#EC4899',
|
||||
},
|
||||
quality: {
|
||||
label: 'Quality',
|
||||
description: 'QA and security assurance',
|
||||
color: '#10B981',
|
||||
},
|
||||
operations: {
|
||||
label: 'Operations',
|
||||
description: 'DevOps and MLOps engineering',
|
||||
color: '#F59E0B',
|
||||
},
|
||||
ai_ml: {
|
||||
label: 'AI & ML',
|
||||
description: 'Machine learning specialists',
|
||||
color: '#8B5CF6',
|
||||
},
|
||||
data: {
|
||||
label: 'Data',
|
||||
description: 'Data science and engineering',
|
||||
color: '#06B6D4',
|
||||
},
|
||||
leadership: {
|
||||
label: 'Leadership',
|
||||
description: 'Technical leadership and facilitation',
|
||||
color: '#F97316',
|
||||
},
|
||||
domain_expert: {
|
||||
label: 'Domain Experts',
|
||||
description: 'Industry and domain specialists',
|
||||
color: '#84CC16',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Base agent type fields shared across create, update, and response schemas
|
||||
*/
|
||||
@@ -20,6 +82,13 @@ export interface AgentTypeBase {
|
||||
mcp_servers: string[];
|
||||
tool_permissions: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
// Category and display fields
|
||||
category?: AgentTypeCategory | null;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
sort_order: number;
|
||||
typical_tasks: string[];
|
||||
collaboration_hints: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +106,13 @@ export interface AgentTypeCreate {
|
||||
mcp_servers?: string[];
|
||||
tool_permissions?: Record<string, unknown>;
|
||||
is_active?: boolean;
|
||||
// Category and display fields
|
||||
category?: AgentTypeCategory | null;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
sort_order?: number;
|
||||
typical_tasks?: string[];
|
||||
collaboration_hints?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +130,13 @@ export interface AgentTypeUpdate {
|
||||
mcp_servers?: string[] | null;
|
||||
tool_permissions?: Record<string, unknown> | null;
|
||||
is_active?: boolean | null;
|
||||
// Category and display fields
|
||||
category?: AgentTypeCategory | null;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
sort_order?: number | null;
|
||||
typical_tasks?: string[] | null;
|
||||
collaboration_hints?: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +155,13 @@ export interface AgentTypeResponse {
|
||||
mcp_servers: string[];
|
||||
tool_permissions: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
// Category and display fields
|
||||
category: AgentTypeCategory | null;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
sort_order: number;
|
||||
typical_tasks: string[];
|
||||
collaboration_hints: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
@@ -104,9 +194,15 @@ export interface AgentTypeListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
is_active?: boolean;
|
||||
category?: AgentTypeCategory;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for grouped agent types by category
|
||||
*/
|
||||
export type AgentTypeGroupedResponse = Record<string, AgentTypeResponse[]>;
|
||||
|
||||
/**
|
||||
* Model parameter configuration with typed fields
|
||||
*/
|
||||
|
||||
118
frontend/src/lib/forms/hooks/useValidationErrorHandler.ts
Normal file
118
frontend/src/lib/forms/hooks/useValidationErrorHandler.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Validation Error Handler Hook
|
||||
*
|
||||
* Handles client-side Zod/react-hook-form validation errors with:
|
||||
* - Toast notifications
|
||||
* - Optional tab navigation
|
||||
* - Debug logging
|
||||
*
|
||||
* @module lib/forms/hooks/useValidationErrorHandler
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { FieldErrors, FieldValues } from 'react-hook-form';
|
||||
import { getFirstValidationError } from '../utils/getFirstValidationError';
|
||||
|
||||
export interface TabFieldMapping {
|
||||
/** Map of field names to tab values */
|
||||
[fieldName: string]: string;
|
||||
}
|
||||
|
||||
export interface UseValidationErrorHandlerOptions {
|
||||
/**
|
||||
* Map of field names (top-level) to tab values.
|
||||
* When an error occurs, navigates to the tab containing the field.
|
||||
*/
|
||||
tabMapping?: TabFieldMapping;
|
||||
|
||||
/**
|
||||
* Callback to set the active tab.
|
||||
* Required if tabMapping is provided.
|
||||
*/
|
||||
setActiveTab?: (tab: string) => void;
|
||||
|
||||
/**
|
||||
* Enable debug logging to console.
|
||||
* @default false in production, true in development
|
||||
*/
|
||||
debug?: boolean;
|
||||
|
||||
/**
|
||||
* Toast title for validation errors.
|
||||
* @default 'Please fix form errors'
|
||||
*/
|
||||
toastTitle?: string;
|
||||
}
|
||||
|
||||
export interface UseValidationErrorHandlerReturn<T extends FieldValues> {
|
||||
/**
|
||||
* Handler function to pass to react-hook-form's handleSubmit second argument.
|
||||
* Shows toast, navigates to tab, and logs errors.
|
||||
*/
|
||||
onValidationError: (errors: FieldErrors<T>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling client-side validation errors
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [activeTab, setActiveTab] = useState('basic');
|
||||
*
|
||||
* const { onValidationError } = useValidationErrorHandler({
|
||||
* tabMapping: {
|
||||
* name: 'basic',
|
||||
* slug: 'basic',
|
||||
* primary_model: 'model',
|
||||
* model_params: 'model',
|
||||
* },
|
||||
* setActiveTab,
|
||||
* });
|
||||
*
|
||||
* // In form:
|
||||
* <form onSubmit={handleSubmit(onSuccess, onValidationError)}>
|
||||
* ```
|
||||
*/
|
||||
export function useValidationErrorHandler<T extends FieldValues>(
|
||||
options: UseValidationErrorHandlerOptions = {}
|
||||
): UseValidationErrorHandlerReturn<T> {
|
||||
const {
|
||||
tabMapping,
|
||||
setActiveTab,
|
||||
debug = process.env.NODE_ENV === 'development',
|
||||
toastTitle = 'Please fix form errors',
|
||||
} = options;
|
||||
|
||||
const onValidationError = useCallback(
|
||||
(errors: FieldErrors<T>) => {
|
||||
// Log errors in debug mode
|
||||
if (debug) {
|
||||
console.error('[Form Validation] Errors:', errors);
|
||||
}
|
||||
|
||||
// Get first error for toast
|
||||
const firstError = getFirstValidationError(errors);
|
||||
if (!firstError) return;
|
||||
|
||||
// Show toast
|
||||
toast.error(toastTitle, {
|
||||
description: `${firstError.field}: ${firstError.message}`,
|
||||
});
|
||||
|
||||
// Navigate to tab if mapping provided
|
||||
if (tabMapping && setActiveTab) {
|
||||
const topLevelField = firstError.field.split('.')[0];
|
||||
const targetTab = tabMapping[topLevelField];
|
||||
if (targetTab) {
|
||||
setActiveTab(targetTab);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabMapping, setActiveTab, debug, toastTitle]
|
||||
);
|
||||
|
||||
return { onValidationError };
|
||||
}
|
||||
30
frontend/src/lib/forms/index.ts
Normal file
30
frontend/src/lib/forms/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Form Utilities and Hooks
|
||||
*
|
||||
* Centralized exports for form-related utilities.
|
||||
*
|
||||
* @module lib/forms
|
||||
*/
|
||||
|
||||
// Utils
|
||||
export { getFirstValidationError, getAllValidationErrors } from './utils/getFirstValidationError';
|
||||
export type { ValidationError } from './utils/getFirstValidationError';
|
||||
|
||||
export {
|
||||
safeValue,
|
||||
isNumber,
|
||||
isString,
|
||||
isBoolean,
|
||||
isArray,
|
||||
isObject,
|
||||
deepMergeWithDefaults,
|
||||
createFormInitializer,
|
||||
} from './utils/mergeWithDefaults';
|
||||
|
||||
// Hooks
|
||||
export { useValidationErrorHandler } from './hooks/useValidationErrorHandler';
|
||||
export type {
|
||||
TabFieldMapping,
|
||||
UseValidationErrorHandlerOptions,
|
||||
UseValidationErrorHandlerReturn,
|
||||
} from './hooks/useValidationErrorHandler';
|
||||
84
frontend/src/lib/forms/utils/getFirstValidationError.ts
Normal file
84
frontend/src/lib/forms/utils/getFirstValidationError.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Get First Validation Error
|
||||
*
|
||||
* Extracts the first error from react-hook-form FieldErrors,
|
||||
* including support for nested errors (e.g., model_params.temperature).
|
||||
*
|
||||
* @module lib/forms/utils/getFirstValidationError
|
||||
*/
|
||||
|
||||
import type { FieldErrors, FieldValues } from 'react-hook-form';
|
||||
|
||||
export interface ValidationError {
|
||||
/** Field path (e.g., 'name' or 'model_params.temperature') */
|
||||
field: string;
|
||||
/** Error message */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract the first error from FieldErrors
|
||||
*
|
||||
* @param errors - FieldErrors object from react-hook-form
|
||||
* @param prefix - Current field path prefix for nested errors
|
||||
* @returns First validation error found, or null if no errors
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const errors = { model_params: { temperature: { message: 'Required' } } };
|
||||
* const error = getFirstValidationError(errors);
|
||||
* // { field: 'model_params.temperature', message: 'Required' }
|
||||
* ```
|
||||
*/
|
||||
export function getFirstValidationError<T extends FieldValues>(
|
||||
errors: FieldErrors<T>,
|
||||
prefix = ''
|
||||
): ValidationError | null {
|
||||
for (const key of Object.keys(errors)) {
|
||||
const error = errors[key as keyof typeof errors];
|
||||
if (!error || typeof error !== 'object') continue;
|
||||
|
||||
const fieldPath = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
// Check if this is a direct error with a message
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
return { field: fieldPath, message: error.message };
|
||||
}
|
||||
|
||||
// Check if this is a nested object (e.g., model_params.temperature)
|
||||
const nestedError = getFirstValidationError(error as FieldErrors<FieldValues>, fieldPath);
|
||||
if (nestedError) return nestedError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all validation errors as a flat array
|
||||
*
|
||||
* @param errors - FieldErrors object from react-hook-form
|
||||
* @param prefix - Current field path prefix for nested errors
|
||||
* @returns Array of all validation errors
|
||||
*/
|
||||
export function getAllValidationErrors<T extends FieldValues>(
|
||||
errors: FieldErrors<T>,
|
||||
prefix = ''
|
||||
): ValidationError[] {
|
||||
const result: ValidationError[] = [];
|
||||
|
||||
for (const key of Object.keys(errors)) {
|
||||
const error = errors[key as keyof typeof errors];
|
||||
if (!error || typeof error !== 'object') continue;
|
||||
|
||||
const fieldPath = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
result.push({ field: fieldPath, message: error.message });
|
||||
} else {
|
||||
// Nested object without message, recurse
|
||||
result.push(...getAllValidationErrors(error as FieldErrors<FieldValues>, fieldPath));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
169
frontend/src/lib/forms/utils/mergeWithDefaults.ts
Normal file
169
frontend/src/lib/forms/utils/mergeWithDefaults.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Merge With Defaults
|
||||
*
|
||||
* Utilities for safely merging API data with form defaults.
|
||||
* Handles missing fields, type mismatches, and nested objects.
|
||||
*
|
||||
* @module lib/forms/utils/mergeWithDefaults
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely get a value with type checking and default fallback
|
||||
*
|
||||
* @param value - Value to check
|
||||
* @param defaultValue - Default to use if value is invalid
|
||||
* @param typeCheck - Type checking function
|
||||
* @returns Valid value or default
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const temp = safeValue(apiData.temperature, 0.7, (v) => typeof v === 'number');
|
||||
* ```
|
||||
*/
|
||||
export function safeValue<T>(
|
||||
value: unknown,
|
||||
defaultValue: T,
|
||||
typeCheck: (v: unknown) => v is T
|
||||
): T {
|
||||
return typeCheck(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for numbers
|
||||
*/
|
||||
export function isNumber(v: unknown): v is number {
|
||||
return typeof v === 'number' && !Number.isNaN(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for strings
|
||||
*/
|
||||
export function isString(v: unknown): v is string {
|
||||
return typeof v === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for booleans
|
||||
*/
|
||||
export function isBoolean(v: unknown): v is boolean {
|
||||
return typeof v === 'boolean';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for arrays
|
||||
*/
|
||||
export function isArray<T>(v: unknown, itemCheck?: (item: unknown) => item is T): v is T[] {
|
||||
if (!Array.isArray(v)) return false;
|
||||
if (itemCheck) return v.every(itemCheck);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for objects (non-null, non-array)
|
||||
*/
|
||||
export function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects, with source values taking precedence
|
||||
* Only merges values that pass type checking against defaults
|
||||
*
|
||||
* @param defaults - Default values (used as type template)
|
||||
* @param source - Source values to merge (from API)
|
||||
* @returns Merged object with all fields from defaults
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const defaults = { temperature: 0.7, max_tokens: 8192, top_p: 0.95 };
|
||||
* const apiData = { temperature: 0.5 }; // missing max_tokens and top_p
|
||||
* const merged = deepMergeWithDefaults(defaults, apiData);
|
||||
* // { temperature: 0.5, max_tokens: 8192, top_p: 0.95 }
|
||||
* ```
|
||||
*/
|
||||
export function deepMergeWithDefaults<T extends Record<string, unknown>>(
|
||||
defaults: T,
|
||||
source: Partial<T> | null | undefined
|
||||
): T {
|
||||
if (!source) return { ...defaults };
|
||||
|
||||
const result = { ...defaults } as T;
|
||||
|
||||
for (const key of Object.keys(defaults) as Array<keyof T>) {
|
||||
const defaultValue = defaults[key];
|
||||
const sourceValue = source[key];
|
||||
|
||||
// Skip if source doesn't have this key
|
||||
if (!(key in source) || sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle nested objects recursively
|
||||
if (isObject(defaultValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMergeWithDefaults(
|
||||
defaultValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as T[keyof T];
|
||||
continue;
|
||||
}
|
||||
|
||||
// For primitives and arrays, only use source if types match
|
||||
if (typeof sourceValue === typeof defaultValue) {
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
// Special case: default is null but source has a value (nullable fields)
|
||||
else if (defaultValue === null && sourceValue !== null) {
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
// Special case: allow null for nullable fields
|
||||
else if (sourceValue === null && defaultValue === null) {
|
||||
result[key] = null as T[keyof T];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a form values initializer from API data
|
||||
*
|
||||
* This is a higher-order function that creates a type-safe initializer
|
||||
* for transforming API responses into form values with defaults.
|
||||
*
|
||||
* @param defaults - Default form values
|
||||
* @param transform - Optional transform function for custom mapping
|
||||
* @returns Function that takes API data and returns form values
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const initializeAgentForm = createFormInitializer(
|
||||
* defaultAgentTypeValues,
|
||||
* (apiData, defaults) => ({
|
||||
* ...defaults,
|
||||
* name: apiData?.name ?? defaults.name,
|
||||
* model_params: deepMergeWithDefaults(
|
||||
* defaults.model_params,
|
||||
* apiData?.model_params
|
||||
* ),
|
||||
* })
|
||||
* );
|
||||
*
|
||||
* // Usage
|
||||
* const formValues = initializeAgentForm(apiResponse);
|
||||
* ```
|
||||
*/
|
||||
export function createFormInitializer<TForm, TApi = Partial<TForm>>(
|
||||
defaults: TForm,
|
||||
transform?: (apiData: TApi | null | undefined, defaults: TForm) => TForm
|
||||
): (apiData: TApi | null | undefined) => TForm {
|
||||
return (apiData) => {
|
||||
if (transform) {
|
||||
return transform(apiData, defaults);
|
||||
}
|
||||
// Default behavior: deep merge
|
||||
return deepMergeWithDefaults(
|
||||
defaults as Record<string, unknown>,
|
||||
apiData as Record<string, unknown> | null | undefined
|
||||
) as TForm;
|
||||
};
|
||||
}
|
||||
@@ -247,6 +247,15 @@ export function useProjectEvents(
|
||||
* Connect to SSE endpoint
|
||||
*/
|
||||
const connect = useCallback(() => {
|
||||
// In frontend demo mode (MSW), SSE is not supported - skip connection
|
||||
if (config.demo.enabled) {
|
||||
if (config.debug.api) {
|
||||
console.log('[SSE] Demo mode enabled - SSE connections disabled');
|
||||
}
|
||||
updateConnectionState('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent connection if not authenticated or no project ID
|
||||
/* istanbul ignore next -- early return guard, tested via connection state */
|
||||
if (!isAuthenticated || !accessToken || !projectId) {
|
||||
|
||||
@@ -6,12 +6,18 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { AgentTypeCategory } from '@/lib/api/types/agentTypes';
|
||||
|
||||
/**
|
||||
* Slug validation regex: lowercase letters, numbers, and hyphens only
|
||||
*/
|
||||
const slugRegex = /^[a-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* Hex color validation regex
|
||||
*/
|
||||
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
/**
|
||||
* Available AI models for agent types
|
||||
*/
|
||||
@@ -43,6 +49,84 @@ export const AGENT_TYPE_STATUS = [
|
||||
{ value: false, label: 'Inactive' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Agent type categories for organizing agents
|
||||
*/
|
||||
/* istanbul ignore next -- constant declaration */
|
||||
export const AGENT_TYPE_CATEGORIES: {
|
||||
value: AgentTypeCategory;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ value: 'development', label: 'Development', description: 'Product, project, and engineering' },
|
||||
{ value: 'design', label: 'Design', description: 'UI/UX and design research' },
|
||||
{ value: 'quality', label: 'Quality', description: 'QA and security assurance' },
|
||||
{ value: 'operations', label: 'Operations', description: 'DevOps and MLOps engineering' },
|
||||
{ value: 'ai_ml', label: 'AI & ML', description: 'Machine learning specialists' },
|
||||
{ value: 'data', label: 'Data', description: 'Data science and engineering' },
|
||||
{ value: 'leadership', label: 'Leadership', description: 'Technical leadership' },
|
||||
{ value: 'domain_expert', label: 'Domain Experts', description: 'Industry specialists' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Available Lucide icons for agent types
|
||||
*/
|
||||
/* istanbul ignore next -- constant declaration */
|
||||
export const AVAILABLE_ICONS = [
|
||||
// Development
|
||||
{ value: 'clipboard-check', label: 'Clipboard Check', category: 'development' },
|
||||
{ value: 'briefcase', label: 'Briefcase', category: 'development' },
|
||||
{ value: 'file-text', label: 'File Text', category: 'development' },
|
||||
{ value: 'git-branch', label: 'Git Branch', category: 'development' },
|
||||
{ value: 'code', label: 'Code', category: 'development' },
|
||||
{ value: 'server', label: 'Server', category: 'development' },
|
||||
{ value: 'layout', label: 'Layout', category: 'development' },
|
||||
{ value: 'smartphone', label: 'Smartphone', category: 'development' },
|
||||
// Design
|
||||
{ value: 'palette', label: 'Palette', category: 'design' },
|
||||
{ value: 'search', label: 'Search', category: 'design' },
|
||||
// Quality
|
||||
{ value: 'shield', label: 'Shield', category: 'quality' },
|
||||
{ value: 'shield-check', label: 'Shield Check', category: 'quality' },
|
||||
// Operations
|
||||
{ value: 'settings', label: 'Settings', category: 'operations' },
|
||||
{ value: 'settings-2', label: 'Settings 2', category: 'operations' },
|
||||
// AI/ML
|
||||
{ value: 'brain', label: 'Brain', category: 'ai_ml' },
|
||||
{ value: 'microscope', label: 'Microscope', category: 'ai_ml' },
|
||||
{ value: 'eye', label: 'Eye', category: 'ai_ml' },
|
||||
{ value: 'message-square', label: 'Message Square', category: 'ai_ml' },
|
||||
// Data
|
||||
{ value: 'bar-chart', label: 'Bar Chart', category: 'data' },
|
||||
{ value: 'database', label: 'Database', category: 'data' },
|
||||
// Leadership
|
||||
{ value: 'users', label: 'Users', category: 'leadership' },
|
||||
{ value: 'target', label: 'Target', category: 'leadership' },
|
||||
// Domain Expert
|
||||
{ value: 'calculator', label: 'Calculator', category: 'domain_expert' },
|
||||
{ value: 'heart-pulse', label: 'Heart Pulse', category: 'domain_expert' },
|
||||
{ value: 'flask-conical', label: 'Flask', category: 'domain_expert' },
|
||||
{ value: 'lightbulb', label: 'Lightbulb', category: 'domain_expert' },
|
||||
{ value: 'book-open', label: 'Book Open', category: 'domain_expert' },
|
||||
// Generic
|
||||
{ value: 'bot', label: 'Bot', category: 'generic' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Color palette for agent type visual distinction
|
||||
*/
|
||||
/* istanbul ignore next -- constant declaration */
|
||||
export const COLOR_PALETTE = [
|
||||
{ value: '#3B82F6', label: 'Blue', category: 'development' },
|
||||
{ value: '#EC4899', label: 'Pink', category: 'design' },
|
||||
{ value: '#10B981', label: 'Green', category: 'quality' },
|
||||
{ value: '#F59E0B', label: 'Amber', category: 'operations' },
|
||||
{ value: '#8B5CF6', label: 'Purple', category: 'ai_ml' },
|
||||
{ value: '#06B6D4', label: 'Cyan', category: 'data' },
|
||||
{ value: '#F97316', label: 'Orange', category: 'leadership' },
|
||||
{ value: '#84CC16', label: 'Lime', category: 'domain_expert' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Model params schema
|
||||
*/
|
||||
@@ -52,6 +136,20 @@ const modelParamsSchema = z.object({
|
||||
top_p: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Agent type category enum values
|
||||
*/
|
||||
const agentTypeCategoryValues = [
|
||||
'development',
|
||||
'design',
|
||||
'quality',
|
||||
'operations',
|
||||
'ai_ml',
|
||||
'data',
|
||||
'leadership',
|
||||
'domain_expert',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Schema for agent type form fields
|
||||
*/
|
||||
@@ -96,6 +194,23 @@ export const agentTypeFormSchema = z.object({
|
||||
tool_permissions: z.record(z.string(), z.unknown()),
|
||||
|
||||
is_active: z.boolean(),
|
||||
|
||||
// Category and display fields
|
||||
category: z.enum(agentTypeCategoryValues).nullable().optional(),
|
||||
|
||||
icon: z.string().max(50, 'Icon must be less than 50 characters').nullable().optional(),
|
||||
|
||||
color: z
|
||||
.string()
|
||||
.regex(hexColorRegex, 'Color must be a valid hex code (e.g., #3B82F6)')
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
sort_order: z.number().int().min(0).max(1000),
|
||||
|
||||
typical_tasks: z.array(z.string()),
|
||||
|
||||
collaboration_hints: z.array(z.string()),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -138,6 +253,13 @@ export const defaultAgentTypeValues: AgentTypeCreateFormValues = {
|
||||
mcp_servers: [],
|
||||
tool_permissions: {},
|
||||
is_active: false, // Start as draft
|
||||
// Category and display fields
|
||||
category: null,
|
||||
icon: 'bot',
|
||||
color: '#3B82F6',
|
||||
sort_order: 0,
|
||||
typical_tasks: [],
|
||||
collaboration_hints: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,13 @@ Your approach is:
|
||||
mcp_servers: ['gitea', 'knowledge', 'filesystem'],
|
||||
tool_permissions: {},
|
||||
is_active: true,
|
||||
// Category and display fields
|
||||
category: 'development',
|
||||
icon: 'git-branch',
|
||||
color: '#3B82F6',
|
||||
sort_order: 40,
|
||||
typical_tasks: ['Design system architecture', 'Create ADRs'],
|
||||
collaboration_hints: ['backend-engineer', 'frontend-engineer'],
|
||||
created_at: '2025-01-10T00:00:00Z',
|
||||
updated_at: '2025-01-18T00:00:00Z',
|
||||
instance_count: 2,
|
||||
@@ -58,9 +65,8 @@ describe('AgentTypeDetail', () => {
|
||||
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description card', () => {
|
||||
it('renders description in hero header', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Designs system architecture and makes technology decisions')
|
||||
).toBeInTheDocument();
|
||||
@@ -130,7 +136,7 @@ describe('AgentTypeDetail', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
await user.click(screen.getByRole('button', { name: /back to agent types/i }));
|
||||
expect(defaultProps.onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -211,4 +217,146 @@ describe('AgentTypeDetail', () => {
|
||||
);
|
||||
expect(screen.getByText('None configured')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Hero Header', () => {
|
||||
it('renders hero header with agent name', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 1, name: 'Software Architect' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dynamic icon in hero header', () => {
|
||||
const { container } = render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(container.querySelector('svg.lucide-git-branch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies agent color to hero header gradient', () => {
|
||||
const { container } = render(<AgentTypeDetail {...defaultProps} />);
|
||||
const heroHeader = container.querySelector('[style*="linear-gradient"]');
|
||||
expect(heroHeader).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders category badge in hero header', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Development')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows last updated date in hero header', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText(/Last updated:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Jan 18, 2025/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Typical Tasks Card', () => {
|
||||
it('renders "What This Agent Does Best" card', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('What This Agent Does Best')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays all typical tasks', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Design system architecture')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create ADRs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render typical tasks card when empty', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, typical_tasks: [] }} />
|
||||
);
|
||||
expect(screen.queryByText('What This Agent Does Best')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collaboration Hints Card', () => {
|
||||
it('renders "Works Well With" card', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Works Well With')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays collaboration hints as badges', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('backend-engineer')).toBeInTheDocument();
|
||||
expect(screen.getByText('frontend-engineer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render collaboration hints card when empty', () => {
|
||||
render(
|
||||
<AgentTypeDetail
|
||||
{...defaultProps}
|
||||
agentType={{ ...mockAgentType, collaboration_hints: [] }}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Works Well With')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Badge', () => {
|
||||
it('renders category badge with correct label', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Development')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render category badge when category is null', () => {
|
||||
render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, category: null }} />
|
||||
);
|
||||
// Should not have a Development badge in the hero header area
|
||||
// The word "Development" should not appear
|
||||
expect(screen.queryByText('Development')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Details Card', () => {
|
||||
it('renders details card with slug', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Slug')).toBeInTheDocument();
|
||||
expect(screen.getByText('software-architect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders details card with sort order', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Sort Order')).toBeInTheDocument();
|
||||
expect(screen.getByText('40')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders details card with creation date', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
expect(screen.getByText('Created')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Jan 10, 2025/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Icon', () => {
|
||||
it('renders fallback icon when icon is null', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, icon: null }} />
|
||||
);
|
||||
// Should fall back to 'bot' icon
|
||||
expect(container.querySelector('svg.lucide-bot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct icon based on agent type', () => {
|
||||
const agentWithBrainIcon = { ...mockAgentType, icon: 'brain' };
|
||||
const { container } = render(
|
||||
<AgentTypeDetail {...defaultProps} agentType={agentWithBrainIcon} />
|
||||
);
|
||||
expect(container.querySelector('svg.lucide-brain')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Styling', () => {
|
||||
it('applies custom color to instance count', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} />);
|
||||
const instanceCount = screen.getByText('2');
|
||||
expect(instanceCount).toHaveStyle({ color: 'rgb(59, 130, 246)' });
|
||||
});
|
||||
|
||||
it('uses default color when color is null', () => {
|
||||
render(<AgentTypeDetail {...defaultProps} agentType={{ ...mockAgentType, color: null }} />);
|
||||
// Should still render without errors
|
||||
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,13 @@ const mockAgentType: AgentTypeResponse = {
|
||||
mcp_servers: ['gitea'],
|
||||
tool_permissions: {},
|
||||
is_active: true,
|
||||
// Category and display fields
|
||||
category: 'development',
|
||||
icon: 'git-branch',
|
||||
color: '#3B82F6',
|
||||
sort_order: 40,
|
||||
typical_tasks: ['Design system architecture'],
|
||||
collaboration_hints: ['backend-engineer'],
|
||||
created_at: '2025-01-10T00:00:00Z',
|
||||
updated_at: '2025-01-18T00:00:00Z',
|
||||
instance_count: 2,
|
||||
@@ -192,7 +199,8 @@ describe('AgentTypeForm', () => {
|
||||
|
||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||
await user.type(expertiseInput, 'new skill');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
// Click the first "Add" button (for expertise)
|
||||
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||
|
||||
expect(screen.getByText('new skill')).toBeInTheDocument();
|
||||
});
|
||||
@@ -454,7 +462,8 @@ describe('AgentTypeForm', () => {
|
||||
// Agent type already has 'system design'
|
||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||
await user.type(expertiseInput, 'system design');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
// Click the first "Add" button (for expertise)
|
||||
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||
|
||||
// Should still only have one 'system design' badge
|
||||
const badges = screen.getAllByText('system design');
|
||||
@@ -465,7 +474,8 @@ describe('AgentTypeForm', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /^add$/i });
|
||||
// Click the first "Add" button (for expertise)
|
||||
const addButton = screen.getAllByRole('button', { name: /^add$/i })[0];
|
||||
await user.click(addButton);
|
||||
|
||||
// No badges should be added
|
||||
@@ -478,7 +488,8 @@ describe('AgentTypeForm', () => {
|
||||
|
||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||
await user.type(expertiseInput, 'API Design');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
// Click the first "Add" button (for expertise)
|
||||
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||
|
||||
expect(screen.getByText('api design')).toBeInTheDocument();
|
||||
});
|
||||
@@ -489,7 +500,8 @@ describe('AgentTypeForm', () => {
|
||||
|
||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||
await user.type(expertiseInput, ' testing ');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
// Click the first "Add" button (for expertise)
|
||||
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||
|
||||
expect(screen.getByText('testing')).toBeInTheDocument();
|
||||
});
|
||||
@@ -502,7 +514,8 @@ describe('AgentTypeForm', () => {
|
||||
/e.g., system design/i
|
||||
) as HTMLInputElement;
|
||||
await user.type(expertiseInput, 'new skill');
|
||||
await user.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
// Click the first "Add" button (for expertise)
|
||||
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
|
||||
|
||||
expect(expertiseInput.value).toBe('');
|
||||
});
|
||||
@@ -562,4 +575,213 @@ describe('AgentTypeForm', () => {
|
||||
expect(screen.getByText('Edit Agent Type')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category & Display Fields', () => {
|
||||
it('renders category and display section', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByText('Category & Display')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows category select', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows sort order input', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByLabelText(/sort order/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows icon input', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByLabelText(/icon/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows color input', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByLabelText(/color/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills category fields in edit mode', () => {
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
const iconInput = screen.getByLabelText(/icon/i) as HTMLInputElement;
|
||||
expect(iconInput.value).toBe('git-branch');
|
||||
|
||||
const sortOrderInput = screen.getByLabelText(/sort order/i) as HTMLInputElement;
|
||||
expect(sortOrderInput.value).toBe('40');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Typical Tasks Management', () => {
|
||||
it('shows typical tasks section', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByText('Typical Tasks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds typical task when add button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const taskInput = screen.getByPlaceholderText(/e.g., design system architecture/i);
|
||||
await user.type(taskInput, 'Write documentation');
|
||||
// Click the second "Add" button (for typical tasks)
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[1]);
|
||||
|
||||
expect(screen.getByText('Write documentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds typical task on enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const taskInput = screen.getByPlaceholderText(/e.g., design system architecture/i);
|
||||
await user.type(taskInput, 'Write documentation{Enter}');
|
||||
|
||||
expect(screen.getByText('Write documentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes typical task when X button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
// Should have existing typical task
|
||||
expect(screen.getByText('Design system architecture')).toBeInTheDocument();
|
||||
|
||||
// Click remove button
|
||||
const removeButton = screen.getByRole('button', {
|
||||
name: /remove design system architecture/i,
|
||||
});
|
||||
await user.click(removeButton);
|
||||
|
||||
expect(screen.queryByText('Design system architecture')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add duplicate typical tasks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
// Agent type already has 'Design system architecture'
|
||||
const taskInput = screen.getByPlaceholderText(/e.g., design system architecture/i);
|
||||
await user.type(taskInput, 'Design system architecture');
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[1]);
|
||||
|
||||
// Should still only have one badge
|
||||
const badges = screen.getAllByText('Design system architecture');
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not add empty typical task', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
// Click the second "Add" button (for typical tasks) without typing
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[1]);
|
||||
|
||||
// No badges should be added (check that there's no remove button for typical tasks)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /remove write documentation/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collaboration Hints Management', () => {
|
||||
it('shows collaboration hints section', () => {
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
expect(screen.getByText('Collaboration Hints')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds collaboration hint when add button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||
await user.type(hintInput, 'devops-engineer');
|
||||
// Click the third "Add" button (for collaboration hints)
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[2]);
|
||||
|
||||
expect(screen.getByText('devops-engineer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds collaboration hint on enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||
await user.type(hintInput, 'devops-engineer{Enter}');
|
||||
|
||||
expect(screen.getByText('devops-engineer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes collaboration hint when X button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
// Should have existing collaboration hint
|
||||
expect(screen.getByText('backend-engineer')).toBeInTheDocument();
|
||||
|
||||
// Click remove button
|
||||
const removeButton = screen.getByRole('button', { name: /remove backend-engineer/i });
|
||||
await user.click(removeButton);
|
||||
|
||||
expect(screen.queryByText('backend-engineer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('converts collaboration hints to lowercase', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||
await user.type(hintInput, 'DevOps-Engineer');
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[2]);
|
||||
|
||||
expect(screen.getByText('devops-engineer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add duplicate collaboration hints', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} agentType={mockAgentType} />);
|
||||
|
||||
// Agent type already has 'backend-engineer'
|
||||
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i);
|
||||
await user.type(hintInput, 'backend-engineer');
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[2]);
|
||||
|
||||
// Should still only have one badge
|
||||
const badges = screen.getAllByText('backend-engineer');
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not add empty collaboration hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
// Click the third "Add" button (for collaboration hints) without typing
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[2]);
|
||||
|
||||
// No badges should be added
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /remove devops-engineer/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears input after adding collaboration hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentTypeForm {...defaultProps} />);
|
||||
|
||||
const hintInput = screen.getByPlaceholderText(/e.g., backend-engineer/i) as HTMLInputElement;
|
||||
await user.type(hintInput, 'devops-engineer');
|
||||
const addButtons = screen.getAllByRole('button', { name: /^add$/i });
|
||||
await user.click(addButtons[2]);
|
||||
|
||||
expect(hintInput.value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,13 @@ const mockAgentTypes: AgentTypeResponse[] = [
|
||||
mcp_servers: ['gitea', 'knowledge'],
|
||||
tool_permissions: {},
|
||||
is_active: true,
|
||||
// Category and display fields
|
||||
category: 'development',
|
||||
icon: 'clipboard-check',
|
||||
color: '#3B82F6',
|
||||
sort_order: 10,
|
||||
typical_tasks: ['Manage backlog', 'Write user stories'],
|
||||
collaboration_hints: ['business-analyst', 'scrum-master'],
|
||||
created_at: '2025-01-15T00:00:00Z',
|
||||
updated_at: '2025-01-20T00:00:00Z',
|
||||
instance_count: 3,
|
||||
@@ -34,6 +41,13 @@ const mockAgentTypes: AgentTypeResponse[] = [
|
||||
mcp_servers: ['gitea'],
|
||||
tool_permissions: {},
|
||||
is_active: false,
|
||||
// Category and display fields
|
||||
category: 'development',
|
||||
icon: 'git-branch',
|
||||
color: '#3B82F6',
|
||||
sort_order: 40,
|
||||
typical_tasks: ['Design architecture', 'Create ADRs'],
|
||||
collaboration_hints: ['backend-engineer', 'devops-engineer'],
|
||||
created_at: '2025-01-10T00:00:00Z',
|
||||
updated_at: '2025-01-18T00:00:00Z',
|
||||
instance_count: 0,
|
||||
@@ -48,6 +62,10 @@ describe('AgentTypeList', () => {
|
||||
onSearchChange: jest.fn(),
|
||||
statusFilter: 'all',
|
||||
onStatusFilterChange: jest.fn(),
|
||||
categoryFilter: 'all',
|
||||
onCategoryFilterChange: jest.fn(),
|
||||
viewMode: 'grid' as const,
|
||||
onViewModeChange: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
onCreate: jest.fn(),
|
||||
};
|
||||
@@ -194,4 +212,158 @@ describe('AgentTypeList', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
describe('Category Filter', () => {
|
||||
it('renders category filter dropdown', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "All Categories" as default option', () => {
|
||||
render(<AgentTypeList {...defaultProps} categoryFilter="all" />);
|
||||
expect(screen.getByText('All Categories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays category badge on agent cards', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
// Both agents have 'development' category
|
||||
const developmentBadges = screen.getAllByText('Development');
|
||||
expect(developmentBadges.length).toBe(2);
|
||||
});
|
||||
|
||||
it('shows filter hint in empty state when category filter is applied', () => {
|
||||
render(<AgentTypeList {...defaultProps} agentTypes={[]} categoryFilter="design" />);
|
||||
expect(screen.getByText('Try adjusting your search or filters')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('View Mode Toggle', () => {
|
||||
it('renders view mode toggle buttons', () => {
|
||||
render(<AgentTypeList {...defaultProps} />);
|
||||
expect(screen.getByRole('radio', { name: /grid view/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /list view/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders grid view by default', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||
// Grid view uses CSS grid
|
||||
expect(container.querySelector('.grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders list view when viewMode is list', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
// List view uses space-y-3 for vertical stacking
|
||||
expect(container.querySelector('.space-y-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onViewModeChange when grid toggle is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onViewModeChange = jest.fn();
|
||||
render(
|
||||
<AgentTypeList {...defaultProps} viewMode="list" onViewModeChange={onViewModeChange} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /grid view/i }));
|
||||
expect(onViewModeChange).toHaveBeenCalledWith('grid');
|
||||
});
|
||||
|
||||
it('calls onViewModeChange when list toggle is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onViewModeChange = jest.fn();
|
||||
render(
|
||||
<AgentTypeList {...defaultProps} viewMode="grid" onViewModeChange={onViewModeChange} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /list view/i }));
|
||||
expect(onViewModeChange).toHaveBeenCalledWith('list');
|
||||
});
|
||||
|
||||
it('shows list-specific loading skeletons when viewMode is list', () => {
|
||||
const { container } = render(
|
||||
<AgentTypeList {...defaultProps} agentTypes={[]} isLoading={true} viewMode="list" />
|
||||
);
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('List View', () => {
|
||||
it('shows agent info in list rows', () => {
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
expect(screen.getByText('Product Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Software Architect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows category badge in list view', () => {
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
const developmentBadges = screen.getAllByText('Development');
|
||||
expect(developmentBadges.length).toBe(2);
|
||||
});
|
||||
|
||||
it('shows expertise count in list view', () => {
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
// Both agents have 3 expertise areas
|
||||
const expertiseTexts = screen.getAllByText('3 expertise areas');
|
||||
expect(expertiseTexts.length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onSelect when list row is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = jest.fn();
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByText('Product Owner'));
|
||||
expect(onSelect).toHaveBeenCalledWith('type-001');
|
||||
});
|
||||
|
||||
it('supports keyboard navigation on list rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = jest.fn();
|
||||
render(<AgentTypeList {...defaultProps} viewMode="list" onSelect={onSelect} />);
|
||||
|
||||
const rows = screen.getAllByRole('button', { name: /view .* agent type/i });
|
||||
rows[0].focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith('type-001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Icons', () => {
|
||||
it('renders agent icon in grid view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||
// Check for svg icons with lucide classes
|
||||
const icons = container.querySelectorAll('svg.lucide-clipboard-check, svg.lucide-git-branch');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders agent icon in list view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
const icons = container.querySelectorAll('svg.lucide-clipboard-check, svg.lucide-git-branch');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Accent', () => {
|
||||
it('applies color to card border in grid view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="grid" />);
|
||||
const card = container.querySelector('[style*="border-top-color"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies color to row border in list view', () => {
|
||||
const { container } = render(<AgentTypeList {...defaultProps} viewMode="list" />);
|
||||
const row = container.querySelector('[style*="border-left-color"]');
|
||||
expect(row).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Badge Component', () => {
|
||||
it('does not render category badge when category is null', () => {
|
||||
const agentWithNoCategory: AgentTypeResponse = {
|
||||
...mockAgentTypes[0],
|
||||
category: null,
|
||||
};
|
||||
render(<AgentTypeList {...defaultProps} agentTypes={[agentWithNoCategory]} />);
|
||||
expect(screen.queryByText('Development')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
448
frontend/tests/components/forms/FormSelect.test.tsx
Normal file
448
frontend/tests/components/forms/FormSelect.test.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Tests for FormSelect Component
|
||||
* Verifies select field rendering, accessibility, and error handling
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { FormSelect, type SelectOption } from '@/components/forms/FormSelect';
|
||||
|
||||
// Polyfill for Radix UI Select - jsdom doesn't support these browser APIs
|
||||
beforeAll(() => {
|
||||
Element.prototype.hasPointerCapture = jest.fn(() => false);
|
||||
Element.prototype.setPointerCapture = jest.fn();
|
||||
Element.prototype.releasePointerCapture = jest.fn();
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
// Helper wrapper component to provide form context
|
||||
interface TestFormValues {
|
||||
model: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
children,
|
||||
defaultValues = { model: '', category: '' },
|
||||
}: {
|
||||
children: (props: {
|
||||
control: ReturnType<typeof useForm<TestFormValues>>['control'];
|
||||
}) => React.ReactNode;
|
||||
defaultValues?: Partial<TestFormValues>;
|
||||
}) {
|
||||
const form = useForm<TestFormValues>({
|
||||
defaultValues: { model: '', category: '', ...defaultValues },
|
||||
});
|
||||
|
||||
return <FormProvider {...form}>{children({ control: form.control })}</FormProvider>;
|
||||
}
|
||||
|
||||
const mockOptions: SelectOption[] = [
|
||||
{ value: 'claude-opus', label: 'Claude Opus' },
|
||||
{ value: 'claude-sonnet', label: 'Claude Sonnet' },
|
||||
{ value: 'claude-haiku', label: 'Claude Haiku' },
|
||||
];
|
||||
|
||||
describe('FormSelect', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders with label and select trigger', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Primary Model')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with description', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
description="Main model used for this agent"
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Main model used for this agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with custom placeholder', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
placeholder="Choose a model"
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Choose a model')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default placeholder when none provided', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Select primary model')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Required Field', () => {
|
||||
it('shows asterisk when required is true', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show asterisk when required is false', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
required={false}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Options Rendering', () => {
|
||||
it('renders all options when opened', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open the select using fireEvent (works better with Radix UI)
|
||||
fireEvent.click(screen.getByRole('combobox'));
|
||||
|
||||
// Check all options are rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'Claude Opus' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('option', { name: 'Claude Sonnet' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Claude Haiku' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects option when clicked', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open the select and choose an option
|
||||
fireEvent.click(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'Claude Sonnet' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Claude Sonnet' }));
|
||||
|
||||
// The selected value should now be displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toHaveTextContent('Claude Sonnet');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('disables select when disabled prop is true', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables select when disabled prop is false', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
disabled={false}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pre-selected Value', () => {
|
||||
it('displays pre-selected value', () => {
|
||||
render(
|
||||
<TestWrapper defaultValues={{ model: 'claude-opus' }}>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveTextContent('Claude Opus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('links label to select via htmlFor/id', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const label = screen.getByText('Primary Model');
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
expect(label).toHaveAttribute('for', 'model');
|
||||
expect(select).toHaveAttribute('id', 'model');
|
||||
});
|
||||
|
||||
it('sets aria-describedby with description ID when description exists', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
description="Choose the main model"
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveAttribute('aria-describedby', 'model-description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom ClassName', () => {
|
||||
it('applies custom className to wrapper', () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
{({ control }) => (
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
className="custom-class"
|
||||
/>
|
||||
)}
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error message when field has error', () => {
|
||||
function TestComponent() {
|
||||
const form = useForm<TestFormValues>({
|
||||
defaultValues: { model: '', category: '' },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={form.control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Model is required');
|
||||
});
|
||||
|
||||
it('sets aria-invalid when error exists', () => {
|
||||
function TestComponent() {
|
||||
const form = useForm<TestFormValues>({
|
||||
defaultValues: { model: '', category: '' },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={form.control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true');
|
||||
});
|
||||
|
||||
it('sets aria-describedby with error ID when error exists', () => {
|
||||
function TestComponent() {
|
||||
const form = useForm<TestFormValues>({
|
||||
defaultValues: { model: '', category: '' },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={form.control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('aria-describedby', 'model-error');
|
||||
});
|
||||
|
||||
it('combines error and description IDs in aria-describedby', () => {
|
||||
function TestComponent() {
|
||||
const form = useForm<TestFormValues>({
|
||||
defaultValues: { model: '', category: '' },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setError('model', { type: 'required', message: 'Model is required' });
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<FormSelect
|
||||
name="model"
|
||||
control={form.control}
|
||||
label="Primary Model"
|
||||
options={mockOptions}
|
||||
description="Choose the main model"
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
'model-error model-description'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
281
frontend/tests/components/forms/FormTextarea.test.tsx
Normal file
281
frontend/tests/components/forms/FormTextarea.test.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Tests for FormTextarea Component
|
||||
* Verifies textarea field rendering, accessibility, and error handling
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FormTextarea } from '@/components/forms/FormTextarea';
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
|
||||
describe('FormTextarea', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders with label and textarea', () => {
|
||||
render(<FormTextarea label="Description" name="description" />);
|
||||
|
||||
expect(screen.getByLabelText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with description', () => {
|
||||
render(
|
||||
<FormTextarea
|
||||
label="Personality Prompt"
|
||||
name="personality"
|
||||
description="Define the agent's personality and behavior"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Define the agent's personality and behavior")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description before textarea', () => {
|
||||
const { container } = render(
|
||||
<FormTextarea label="Description" name="description" description="Helper text" />
|
||||
);
|
||||
|
||||
const description = container.querySelector('#description-description');
|
||||
const textarea = container.querySelector('textarea');
|
||||
|
||||
// Get positions
|
||||
const descriptionRect = description?.getBoundingClientRect();
|
||||
const textareaRect = textarea?.getBoundingClientRect();
|
||||
|
||||
// Description should appear (both should exist)
|
||||
expect(description).toBeInTheDocument();
|
||||
expect(textarea).toBeInTheDocument();
|
||||
|
||||
// In the DOM order, description comes before textarea
|
||||
expect(descriptionRect).toBeDefined();
|
||||
expect(textareaRect).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Required Field', () => {
|
||||
it('shows asterisk when required is true', () => {
|
||||
render(<FormTextarea label="Description" name="description" required />);
|
||||
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show asterisk when required is false', () => {
|
||||
render(<FormTextarea label="Description" name="description" required={false} />);
|
||||
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error message when error prop is provided', () => {
|
||||
const error: FieldError = {
|
||||
type: 'required',
|
||||
message: 'Description is required',
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||
|
||||
expect(screen.getByText('Description is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets aria-invalid when error exists', () => {
|
||||
const error: FieldError = {
|
||||
type: 'required',
|
||||
message: 'Description is required',
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('aria-invalid', 'true');
|
||||
});
|
||||
|
||||
it('sets aria-describedby with error ID when error exists', () => {
|
||||
const error: FieldError = {
|
||||
type: 'required',
|
||||
message: 'Description is required',
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('aria-describedby', 'description-error');
|
||||
});
|
||||
|
||||
it('renders error with role="alert"', () => {
|
||||
const error: FieldError = {
|
||||
type: 'required',
|
||||
message: 'Description is required',
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||
|
||||
const errorElement = screen.getByRole('alert');
|
||||
expect(errorElement).toHaveTextContent('Description is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('links label to textarea via htmlFor/id', () => {
|
||||
render(<FormTextarea label="Description" name="description" />);
|
||||
|
||||
const label = screen.getByText('Description');
|
||||
const textarea = screen.getByRole('textbox');
|
||||
|
||||
expect(label).toHaveAttribute('for', 'description');
|
||||
expect(textarea).toHaveAttribute('id', 'description');
|
||||
});
|
||||
|
||||
it('sets aria-describedby with description ID when description exists', () => {
|
||||
render(
|
||||
<FormTextarea
|
||||
label="Description"
|
||||
name="description"
|
||||
description="Enter a detailed description"
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('aria-describedby', 'description-description');
|
||||
});
|
||||
|
||||
it('combines error and description IDs in aria-describedby', () => {
|
||||
const error: FieldError = {
|
||||
type: 'required',
|
||||
message: 'Description is required',
|
||||
};
|
||||
|
||||
render(
|
||||
<FormTextarea
|
||||
label="Description"
|
||||
name="description"
|
||||
description="Enter a detailed description"
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
'description-error description-description'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Textarea Props Forwarding', () => {
|
||||
it('forwards textarea props correctly', () => {
|
||||
render(
|
||||
<FormTextarea
|
||||
label="Description"
|
||||
name="description"
|
||||
placeholder="Enter description"
|
||||
rows={5}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('placeholder', 'Enter description');
|
||||
expect(textarea).toHaveAttribute('rows', '5');
|
||||
expect(textarea).toBeDisabled();
|
||||
});
|
||||
|
||||
it('accepts register() props via registration', () => {
|
||||
const registerProps = {
|
||||
name: 'description',
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
ref: jest.fn(),
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Description" registration={registerProps} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea).toHaveAttribute('id', 'description');
|
||||
});
|
||||
|
||||
it('extracts name from spread props', () => {
|
||||
const spreadProps = {
|
||||
name: 'content',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Content" {...spreadProps} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('id', 'content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Cases', () => {
|
||||
it('throws error when name is not provided', () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<FormTextarea label="Description" />);
|
||||
}).toThrow('FormTextarea: name must be provided either explicitly or via register()');
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Styling', () => {
|
||||
it('applies correct spacing classes', () => {
|
||||
const { container } = render(<FormTextarea label="Description" name="description" />);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveClass('space-y-2');
|
||||
});
|
||||
|
||||
it('applies correct error styling', () => {
|
||||
const error: FieldError = {
|
||||
type: 'required',
|
||||
message: 'Description is required',
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Description" name="description" error={error} />);
|
||||
|
||||
const errorElement = screen.getByRole('alert');
|
||||
expect(errorElement).toHaveClass('text-sm', 'text-destructive');
|
||||
});
|
||||
|
||||
it('applies correct description styling', () => {
|
||||
const { container } = render(
|
||||
<FormTextarea label="Description" name="description" description="Helper text" />
|
||||
);
|
||||
|
||||
const description = container.querySelector('#description-description');
|
||||
expect(description).toHaveClass('text-sm', 'text-muted-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Name Priority', () => {
|
||||
it('uses explicit name over registration name', () => {
|
||||
const registerProps = {
|
||||
name: 'fromRegister',
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
ref: jest.fn(),
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Content" name="explicit" registration={registerProps} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('id', 'explicit');
|
||||
});
|
||||
|
||||
it('uses registration name when explicit name not provided', () => {
|
||||
const registerProps = {
|
||||
name: 'fromRegister',
|
||||
onChange: jest.fn(),
|
||||
onBlur: jest.fn(),
|
||||
ref: jest.fn(),
|
||||
};
|
||||
|
||||
render(<FormTextarea label="Content" registration={registerProps} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveAttribute('id', 'fromRegister');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user