45 Commits

Author SHA1 Message Date
Felipe Cardoso
4ad3d20cf2 chore(agents): update sort_order values for agent types to improve logical grouping 2026-01-06 18:43:29 +01:00
Felipe Cardoso
8623eb56f5 feat(agents): add sorting by sort_order and include category & display fields in agent actions
- Implemented sorting of agent types by `sort_order` in Agents page.
- Added support for category, icon, color, sort_order, typical_tasks, and collaboration_hints fields in agent creation and update actions.
2026-01-06 18:20:04 +01:00
Felipe Cardoso
3cb6c8d13b feat(agents): implement grid/list view toggle and enhance filters
- Added grid and list view modes to AgentTypeList with user preference management.
- Enhanced filtering with category selection alongside existing search and status filters.
- Updated AgentTypeDetail with category badges and improved layout.
- Added unit tests for grid/list views and category filtering in AgentTypeList.
- Introduced `@radix-ui/react-toggle-group` for view mode toggle in AgentTypeList.
2026-01-06 18:17:46 +01:00
Felipe Cardoso
8e16e2645e test(forms): add unit tests for FormTextarea and FormSelect components
- Add comprehensive test coverage for FormTextarea and FormSelect components to validate rendering, accessibility, props forwarding, error handling, and behavior.
- Introduced function-scoped fixtures in e2e tests to ensure test isolation and address event loop issues with pytest-asyncio and SQLAlchemy.
2026-01-06 17:54:49 +01:00
Felipe Cardoso
82c3a6ba47 chore(makefiles): add format-check target and unify formatting logic
- Introduced `format-check` for verification without modification in `llm-gateway` and `knowledge-base` Makefiles.
- Updated `validate` to include `format-check`.
- Added `format-all` to root Makefile for consistent formatting across all components.
- Unexported `VIRTUAL_ENV` to prevent virtual environment warnings.
2026-01-06 17:25:21 +01:00
Felipe Cardoso
b6c38cac88 refactor(llm-gateway): adjust if-condition formatting for thread safety check
Updated line breaks and indentation for improved readability in circuit state recovery logic, ensuring consistent style.
2026-01-06 17:20:49 +01:00
Felipe Cardoso
51404216ae refactor(knowledge-base mcp server): adjust formatting for consistency and readability
Improved code formatting, line breaks, and indentation across chunking logic and multiple test modules to enhance code clarity and maintain consistent style. No functional changes made.
2026-01-06 17:20:31 +01:00
Felipe Cardoso
3f23bc3db3 refactor(migrations): replace hardcoded database URL with configurable environment variable and update command syntax to use consistent quoting style 2026-01-06 17:19:28 +01:00
Felipe Cardoso
a0ec5fa2cc test(agents): add validation tests for category and display fields
Added comprehensive unit and API tests to validate AgentType category and display fields:
- Category validation for valid, null, and invalid values
- Icon, color, and sort_order field constraints
- Typical tasks and collaboration hints handling (stripping, removing empty strings, normalization)
- New API tests for field creation, filtering, updating, and grouping
2026-01-06 17:19:21 +01:00
Felipe Cardoso
f262d08be2 test(project-events): add tests for demo configuration defaults
Added unit test cases to verify that the `demo.enabled` field is properly initialized to `false` in configurations and mock overrides.
2026-01-06 17:08:35 +01:00
Felipe Cardoso
b3f371e0a3 test(agents): add tests for AgentTypeForm enhancements
Added unit tests to cover new AgentTypeForm features:
- Category & Display fields (category select, sort order, icon, color)
- Typical Tasks management (add, remove, and prevent duplicates)
- Collaboration Hints management (add, remove, lowercase, and prevent duplicates)

This ensures thorough validation of recent form updates.
2026-01-06 17:07:21 +01:00
Felipe Cardoso
93cc37224c feat(agents): add category and display fields to AgentTypeForm
Add new "Category & Display" card in Basic Info tab with:
- Category dropdown to select agent category
- Sort order input for display ordering
- Icon text input with Lucide icon name
- Color picker with hex input and visual color selector
- Typical tasks tag input for agent capabilities
- Collaboration hints tag input for agent relationships

Updates include:
- TAB_FIELD_MAPPING with new field mappings
- State and handlers for typical_tasks and collaboration_hints
- Fix tests to use getAllByRole for multiple Add buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 16:21:28 +01:00
Felipe Cardoso
5717bffd63 feat(agents): add frontend types and validation for category fields
Frontend changes to support new AgentType category and display fields:

Types (agentTypes.ts):
- Add AgentTypeCategory union type with 8 categories
- Add CATEGORY_METADATA constant with labels, descriptions, colors
- Update all interfaces with new fields (category, icon, color, etc.)
- Add AgentTypeGroupedResponse type

Validation (agentType.ts):
- Add AGENT_TYPE_CATEGORIES constant with metadata
- Add AVAILABLE_ICONS constant for icon picker
- Add COLOR_PALETTE constant for color selection
- Update agentTypeFormSchema with new field validators
- Update defaultAgentTypeValues with new fields

Form updates:
- Transform function now maps category and display fields from API

Test updates:
- Add new fields to mock AgentTypeResponse objects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 16:16:21 +01:00
Felipe Cardoso
9339ea30a1 feat(agents): add category and display fields to AgentType model
Add 6 new fields to AgentType for better organization and UI display:
- category: enum for grouping (development, design, quality, etc.)
- icon: Lucide icon identifier for UI
- color: hex color code for visual distinction
- sort_order: display ordering within categories
- typical_tasks: list of tasks the agent excels at
- collaboration_hints: agent slugs that work well together

Backend changes:
- Add AgentTypeCategory enum to enums.py
- Update AgentType model with 6 new columns and indexes
- Update schemas with validators for new fields
- Add category filter and /grouped endpoint to routes
- Update CRUD with get_grouped_by_category method
- Update seed data with categories for all 27 agents
- Add migration 0007

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 16:11:22 +01:00
Felipe Cardoso
79cb6bfd7b feat(agents): comprehensive agent types with rich personalities
Major revamp of agent types based on SOTA personality design research:
- Expanded from 6 to 27 specialized agent types
- Rich personality prompts following Anthropic and CrewAI best practices
- Each agent has structured prompt with Core Identity, Expertise,
  Principles, and Scenario Handling sections

Agent Categories:
- Core Development (8): Product Owner, PM, BA, Architect, Full Stack,
  Backend, Frontend, Mobile Engineers
- Design (2): UI/UX Designer, UX Researcher
- Quality & Operations (3): QA, DevOps, Security Engineers
- AI/ML (5): AI/ML Engineer, Researcher, CV, NLP, MLOps Engineers
- Data (2): Data Scientist, Data Engineer
- Leadership (2): Technical Lead, Scrum Master
- Domain Specialists (5): Financial, Healthcare, Scientific,
  Behavioral Psychology Experts, Technical Writer

Research applied:
- Anthropic Claude persona design guidelines
- CrewAI role/backstory/goal patterns
- Role prompting research on detailed vs generic personas
- Temperature tuning per agent type (0.2-0.7 based on role)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 14:25:13 +01:00
Felipe Cardoso
45025bb2f1 fix(forms): handle nullable fields in deepMergeWithDefaults
When default value is null but source has a value (e.g., description
field), the merge was discarding the source value because typeof null
!== typeof string. Now properly accepts source values for nullable fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:54:18 +01:00
Felipe Cardoso
3c6b14d2bf refactor(forms): extract reusable form utilities and components
- Add getFirstValidationError utility for nested FieldErrors extraction
- Add mergeWithDefaults utilities (deepMergeWithDefaults, type guards)
- Add useValidationErrorHandler hook for toast + tab navigation
- Add FormSelect component with Controller integration
- Add FormTextarea component with register integration
- Refactor AgentTypeForm to use new utilities
- Remove verbose debug logging (now handled by hook)
- Add comprehensive tests (53 new tests, 100 total)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:50:36 +01:00
Felipe Cardoso
6b21a6fadd debug(agents): add comprehensive logging to form submission
Adds console.log statements throughout the form submission flow:
- Form submit triggered
- Current form values
- Form state (isDirty, isValid, isSubmitting, errors)
- Validation pass/fail
- onSubmit call and completion

This will help diagnose why the save button appears to do nothing.
Check browser console for '[AgentTypeForm]' logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:56:54 +01:00
Felipe Cardoso
600657adc4 fix(agents): properly initialize form with API data defaults
Root cause: The demo data's model_params was missing `top_p`, but the
Zod schema required all three fields (temperature, max_tokens, top_p).
This caused silent validation failures when editing agent types.

Fixes:
1. Add getInitialValues() that ensures all required fields have defaults
2. Handle nested validation errors in handleFormError (e.g., model_params.top_p)
3. Add useEffect to reset form when agentType changes
4. Add console.error logging for debugging validation failures
5. Update demo data to include top_p in all agent types

The form now properly initializes with safe defaults for any missing
fields from the API response, preventing silent validation failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:54:45 +01:00
Felipe Cardoso
c9d0d079b3 fix(frontend): show validation errors when agent type form fails
When form validation fails (e.g., personality_prompt is empty), the form
would silently not submit. Now it shows a toast with the first error
and navigates to the tab containing the error field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:29:01 +01:00
Felipe Cardoso
4c8f81368c fix(docker): add NEXT_PUBLIC_API_BASE_URL to frontend containers
When running in Docker, the frontend needs to use 'http://backend:8000'
as the backend URL for Next.js rewrites. This env var is set to use
the Docker service name for proper container-to-container communication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:23:50 +01:00
Felipe Cardoso
efbe91ce14 fix(frontend): use configurable backend URL in Next.js rewrite
The rewrite was using 'http://backend:8000' which only resolves inside
Docker network. When running Next.js locally (npm run dev), the hostname
'backend' doesn't exist, causing ENOTFOUND errors.

Now uses NEXT_PUBLIC_API_BASE_URL env var with fallback to localhost:8000
for local development. In Docker, set NEXT_PUBLIC_API_BASE_URL=http://backend:8000.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:22:44 +01:00
Felipe Cardoso
5d646779c9 fix(frontend): preserve /api prefix in Next.js rewrite
The rewrite was incorrectly configured:
- Before: /api/:path* -> http://backend:8000/:path* (strips /api)
- After: /api/:path* -> http://backend:8000/api/:path* (preserves /api)

This was causing requests to /api/v1/agent-types to be sent to
http://backend:8000/v1/agent-types instead of the correct path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 03:12:08 +01:00
Felipe Cardoso
5a4d93df26 feat(dashboard): use real API data and add 3 more demo projects
Dashboard changes:
- Update useDashboard hook to fetch real projects from API
- Calculate stats (active projects, agents, issues) from real data
- Keep pending approvals as mock (no backend endpoint yet)

Demo data additions:
- API Gateway Modernization project (active, complex)
- Customer Analytics Dashboard project (completed)
- DevOps Pipeline Automation project (active, complex)
- Added sprints, agent instances, and issues for each new project

Total demo data: 6 projects, 14 agents, 22 issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 03:10:10 +01:00
Felipe Cardoso
7ef217be39 feat(demo): tie all demo projects to admin user
- Update demo_data.json to use "__admin__" as owner_email for all projects
- Add admin user lookup in load_demo_data() with special "__admin__" key
- Remove notification_email from project settings (not a valid field)

This ensures demo projects are visible to the admin user when logged in.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 03:00:07 +01:00
Felipe Cardoso
20159c5865 fix(knowledge-base): ensure pgvector extension before pool creation
register_vector() requires the vector type to exist in PostgreSQL before
it can register the type codec. Move CREATE EXTENSION to a separate
_ensure_pgvector_extension() method that runs before pool creation.

This fixes the "unknown type: public.vector" error on fresh databases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 02:55:02 +01:00
Felipe Cardoso
f9a72fcb34 fix(models): use enum values instead of names for PostgreSQL
Add values_callable to all enum columns so SQLAlchemy serializes using
the enum's .value (lowercase) instead of .name (uppercase). PostgreSQL
enum types defined in migrations use lowercase values.

Fixes: invalid input value for enum autonomy_level: "MILESTONE"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 02:53:45 +01:00
Felipe Cardoso
fcb0a5f86a fix(models): add explicit enum names to match migration types
SQLAlchemy's Enum() auto-generates type names from Python class names
(e.g., AutonomyLevel -> autonomylevel), but migrations defined them
with underscores (e.g., autonomy_level). This mismatch caused:

  "type 'autonomylevel' does not exist"

Added explicit name parameters to all enum columns to match the
migration-defined type names:
- autonomy_level, project_status, project_complexity, client_mode
- agent_status, sprint_status
- issue_type, issue_status, issue_priority, sync_status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 02:48:10 +01:00
Felipe Cardoso
92782bcb05 refactor(init_db): remove demo data file and implement structured seeding
- Delete `demo_data.json` replaced by structured logic for better modularity.
- Add support for seeding default agent types and new demo data structure.
- Ensure demo mode only executes when explicitly enabled (settings.DEMO_MODE).
- Enhance logging for improved debugging during DB initialization.
2026-01-06 02:34:34 +01:00
Felipe Cardoso
1dcf99ee38 fix(memory): use deque for metrics histograms to ensure bounded memory usage
- Replace default empty list with `deque` for `memory_retrieval_latency_seconds`
- Prevents unbounded memory growth by leveraging bounded circular buffer behavior
2026-01-06 02:34:28 +01:00
Felipe Cardoso
70009676a3 fix(dashboard): disable SSE in demo mode and remove unused hooks
- Skip SSE connection in demo mode (MSW doesn't support SSE).
- Remove unused `useProjectEvents` and related real-time hooks from `Dashboard`.
- Temporarily disable activity feed SSE until a global endpoint is available.
2026-01-06 02:29:00 +01:00
Felipe Cardoso
192237e69b fix(memory): unify Outcome enum and add ABANDONED support
- Add ABANDONED value to core Outcome enum in types.py
- Replace duplicate OutcomeType class in mcp/tools.py with alias to Outcome
- Simplify mcp/service.py to use outcome directly (no more silent mapping)
- Add migration 0006 to extend PostgreSQL episode_outcome enum
- Add missing constraints to migration 0005 (ix_facts_unique_triple_global)

This fixes the semantic issue where ABANDONED outcomes were silently
converted to FAILURE, losing information about task abandonment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 01:46:48 +01:00
Felipe Cardoso
3edce9cd26 fix(memory): address critical bugs from multi-agent review
Bug Fixes:
- Remove singleton pattern from consolidation/reflection services to
  prevent stale database session bugs (session is now passed per-request)
- Add LRU eviction to MemoryToolService._working dict (max 1000 sessions)
  to prevent unbounded memory growth
- Replace O(n) list.remove() with O(1) OrderedDict.move_to_end() in
  RetrievalCache for better performance under load
- Use deque with maxlen for metrics histograms to prevent unbounded
  memory growth (circular buffer with 10k max samples)
- Use full UUID for checkpoint IDs instead of 8-char prefix to avoid
  collision risk at scale (birthday paradox at ~50k checkpoints)

Test Updates:
- Update checkpoint test to expect 36-char UUID
- Update reflection singleton tests to expect new factory behavior
- Add reset_memory_reflection() no-op for backwards compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:55:32 +01:00
Felipe Cardoso
35aea2d73a perf(mcp): optimize test performance with parallel connections and reduced retries
- Connect to MCP servers concurrently instead of sequentially
- Reduce retry settings in test mode (IS_TEST=True):
  - 1 attempt instead of 3
  - 100ms retry delay instead of 1s
  - 2s timeout instead of 30-120s

Reduces MCP E2E test time from ~16s to under 1s.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:33:38 +01:00
Felipe Cardoso
d0f32d04f7 fix(tests): reduce TTL durations to improve test reliability
- Adjusted TTL durations and sleep intervals across memory and cache tests for consistent expiration behavior.
- Prevented test flakiness caused by timing discrepancies in token expiration and cache cleanup.
2026-01-05 18:29:02 +01:00
Felipe Cardoso
da85a8aba8 fix(memory): prevent entry metadata mutation in vector search
- Create shallow copy of VectorIndexEntry when adding similarity score
- Prevents mutation of cached entries that could corrupt shared state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:39:54 +01:00
Felipe Cardoso
f8bd1011e9 security(memory): escape SQL ILIKE patterns to prevent injection
- Add _escape_like_pattern() helper to escape SQL wildcards (%, _, \)
- Apply escaping in SemanticMemory.search_facts and get_by_entity
- Apply escaping in ProceduralMemory.search and find_best_for_task

Prevents attackers from injecting SQL wildcard patterns through
user-controlled search terms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:39:47 +01:00
Felipe Cardoso
f057c2f0b6 fix(memory): add thread-safe singleton initialization
- Add threading.Lock with double-check locking to ScopeManager
- Add asyncio.Lock with double-check locking to MemoryReflection
- Make reset_memory_metrics async with proper locking
- Update test fixtures to handle async reset functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:39:39 +01:00
Felipe Cardoso
33ec889fc4 fix(memory): add data integrity constraints to Fact model
- Change source_episode_ids from JSON to JSONB for PostgreSQL consistency
- Add unique constraint for global facts (project_id IS NULL)
- Add CHECK constraint ensuring reinforcement_count >= 1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:39:30 +01:00
Felipe Cardoso
74b8c65741 fix(tests): move memory model tests to avoid import conflicts
Moved tests/unit/models/memory/ to tests/models/memory/ to avoid
Python import path conflicts when pytest collects all tests.

The conflict was caused by tests/models/ and tests/unit/models/ both
having __init__.py files, causing Python to confuse app.models.memory
imports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:45:30 +01:00
Felipe Cardoso
b232298c61 feat(memory): add memory consolidation task and switch source_episode_ids to JSON
- Added `memory_consolidation` to the task list and updated `__all__` in test files.
- Updated `source_episode_ids` in `Fact` model to use JSON for cross-database compatibility.
- Revised related database migrations to use JSONB instead of ARRAY.
- Adjusted test concurrency in Makefile for improved test performance.
2026-01-05 15:38:52 +01:00
Felipe Cardoso
cf6291ac8e style(memory): apply ruff formatting and linting fixes
Auto-fixed linting errors and formatting issues:
- Removed unused imports (F401): pytest, Any, AnalysisType, MemoryType, OutcomeType
- Removed unused variable (F841): hooks variable in test
- Applied consistent formatting across memory service and test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:07:48 +01:00
Felipe Cardoso
e3fe0439fd docs(memory): add comprehensive memory system documentation (#101)
Add complete documentation for the Agent Memory System including:
- Architecture overview with ASCII diagram
- Memory type descriptions (working, episodic, semantic, procedural)
- Usage examples for all memory operations
- Memory scoping hierarchy explanation
- Consolidation flow documentation
- MCP tools reference
- Reflection capabilities
- Configuration reference table
- Integration with Context Engine
- Metrics reference
- Performance targets
- Troubleshooting guide
- Directory structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 11:03:57 +01:00
Felipe Cardoso
57680c3772 feat(memory): implement metrics and observability (#100)
Add comprehensive metrics collector for memory system with:
- Counter metrics: operations, retrievals, cache hits/misses, consolidations,
  episodes recorded, patterns/anomalies/insights detected
- Gauge metrics: item counts, memory size, cache size, procedure success rates,
  active sessions, pending consolidations
- Histogram metrics: working memory latency, retrieval latency, consolidation
  duration, embedding latency
- Prometheus format export
- Summary and cache stats helpers

31 tests covering all metric types, singleton pattern, and edge cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 11:00:53 +01:00
Felipe Cardoso
997cfaa03a feat(memory): implement memory reflection service (#99)
Add reflection layer for memory system with pattern detection, success/failure
factor analysis, anomaly detection, and insights generation. Enables agents to
learn from past experiences and identify optimization opportunities.

Key components:
- Pattern detection: recurring success/failure, action sequences, temporal, efficiency
- Factor analysis: action, context, timing, resource, preceding state factors
- Anomaly detection: unusual duration, token usage, failure rates, action patterns
- Insight generation: optimization, warning, learning, recommendation, trend insights

Also fixes pre-existing timezone issues in test_types.py (datetime.now() -> datetime.now(UTC)).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 04:22:23 +01:00
124 changed files with 12926 additions and 1166 deletions

View File

@@ -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"
@@ -161,6 +164,25 @@ test-integration:
@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 frontend..."
@cd frontend && npm run format
@echo ""
@echo "All code formatted!"
# ============================================================================
# Validation (lint + type-check + test)
# ============================================================================

View File

@@ -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"
# ============================================================================

View File

@@ -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")

View 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")

View File

@@ -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")

View File

@@ -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,

View File

@@ -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
}
]
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -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:

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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] = {}

View File

@@ -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.

View File

@@ -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:

View File

@@ -90,6 +90,9 @@ from .types import (
WorkingMemoryItem,
)
# Reflection (lazy import available)
# Import directly: from app.services.memory.reflection import MemoryReflection
__all__ = [
"CheckpointError",
"ConsolidationStatus",

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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()

View File

@@ -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)
# ============================================================================

View 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",
]

View 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)

View File

@@ -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),
),
)
)

View 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",
]

File diff suppressed because it is too large Load Diff

View 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)

View File

@@ -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

View File

@@ -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))

View File

@@ -42,6 +42,7 @@ class Outcome(str, Enum):
SUCCESS = "success"
FAILURE = "failure"
PARTIAL = "partial"
ABANDONED = "abandoned"
class ConsolidationStatus(str, Enum):

View File

@@ -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

File diff suppressed because it is too large Load Diff

879
backend/data/demo_data.json Normal file
View 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
}
]
}

View 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
```

View File

@@ -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:

View File

@@ -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

View File

@@ -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",

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
):

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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()),

View File

@@ -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()

View File

@@ -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"
)

View File

@@ -0,0 +1,2 @@
# tests/unit/services/memory/metrics/__init__.py
"""Tests for Memory Metrics."""

View 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

View File

@@ -0,0 +1,2 @@
# tests/unit/services/memory/reflection/__init__.py
"""Tests for Memory Reflection."""

View 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

View 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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View File

@@ -249,6 +249,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

View File

@@ -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*`,
},
];
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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', {

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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

View File

@@ -5,5 +5,5 @@
*/
export { AgentTypeForm } from './AgentTypeForm';
export { AgentTypeList } from './AgentTypeList';
export { AgentTypeList, type ViewMode } from './AgentTypeList';
export { AgentTypeDetail } from './AgentTypeDetail';

View File

@@ -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

View 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>
)}
/>
);
}

View 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>
);
}

View File

@@ -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';

View 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);
}

View 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 };

View File

@@ -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;

View File

@@ -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,

View File

@@ -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
*/

View 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 };
}

View 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';

View 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;
}

View 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;
};
}

View File

@@ -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) {

View File

@@ -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: [],
};
/**

View File

@@ -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();
});
});
});

View File

@@ -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('');
});
});
});

View File

@@ -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();
});
});
});

View 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'
);
});
});
});

View 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