Spaces:
Sleeping
Sleeping
| """ | |
| Coverage expansion tests for episode services. | |
| Tests cover critical code paths in: | |
| - episode_segmentation_service.py: Episode segmentation logic | |
| - episode_lifecycle_service.py: Episode creation, completion, archival | |
| - agent_graduation_service.py: Graduation criteria evaluation | |
| Target: Cover critical paths (happy path + error paths) to increase coverage. | |
| """ | |
| import pytest | |
| from unittest.mock import Mock, patch, MagicMock | |
| from datetime import datetime, timedelta | |
| from sqlalchemy.orm import Session | |
| from core.episode_segmentation_service import ( | |
| EpisodeSegmentationService, | |
| EpisodeBoundaryDetector, | |
| TIME_GAP_THRESHOLD_MINUTES, | |
| SEMANTIC_SIMILARITY_THRESHOLD | |
| ) | |
| from core.models import AgentRegistry, AgentStatus, ChatMessage, AgentExecution | |
| class TestEpisodeBoundaryDetectorCoverage: | |
| """Coverage expansion for EpisodeBoundaryDetector.""" | |
| def detector(self): | |
| """Get boundary detector instance.""" | |
| lancedb_mock = Mock() | |
| lancedb_mock.embed_text = Mock(return_value=None) | |
| return EpisodeBoundaryDetector(lancedb_mock) | |
| # Test: time gap detection | |
| def test_detect_time_gap_exceeds_threshold(self, detector): | |
| """Detect time gaps > threshold between messages.""" | |
| now = datetime.now() | |
| messages = [ | |
| ChatMessage(id="1", content="First", created_at=now - timedelta(minutes=60)), | |
| ChatMessage(id="2", content="Second", created_at=now - timedelta(minutes=30)), | |
| ChatMessage(id="3", content="Third", created_at=now), | |
| ] | |
| gaps = detector.detect_time_gap(messages) | |
| # Should detect gap between message 1 and 2 (60 min > 30 min threshold) | |
| assert len(gaps) >= 1 | |
| assert 1 in gaps # Gap after first message | |
| def test_detect_time_gap_below_threshold(self, detector): | |
| """Don't detect gaps below threshold.""" | |
| now = datetime.now() | |
| messages = [ | |
| ChatMessage(id="1", content="First", created_at=now - timedelta(minutes=5)), | |
| ChatMessage(id="2", content="Second", created_at=now - timedelta(minutes=2)), | |
| ChatMessage(id="3", content="Third", created_at=now), | |
| ] | |
| gaps = detector.detect_time_gap(messages) | |
| # No gaps > 30 minutes | |
| assert len(gaps) == 0 | |
| def test_detect_time_gap_exactly_threshold(self, detector): | |
| """Gap of exactly threshold minutes does NOT trigger new segment.""" | |
| now = datetime.now() | |
| messages = [ | |
| ChatMessage(id="1", content="First", created_at=now - timedelta(minutes=30)), | |
| ChatMessage(id="2", content="Second", created_at=now), | |
| ] | |
| gaps = detector.detect_time_gap(messages) | |
| # 30 minutes is NOT > 30, so no gap | |
| assert len(gaps) == 0 | |
| def test_detect_time_gap_empty_messages(self, detector): | |
| """Handle empty message list.""" | |
| gaps = detector.detect_time_gap([]) | |
| assert gaps == [] | |
| def test_detect_time_gap_single_message(self, detector): | |
| """Handle single message (no gaps possible).""" | |
| messages = [ | |
| ChatMessage(id="1", content="First", created_at=datetime.now()) | |
| ] | |
| gaps = detector.detect_time_gap(messages) | |
| assert gaps == [] | |
| # Test: topic change detection | |
| def test_detect_topic_changes_with_embeddings(self, detector): | |
| """Detect topic changes using semantic similarity.""" | |
| # Mock embeddings with low similarity | |
| detector.db.embed_text = Mock(side_effect=[ | |
| [0.1, 0.2, 0.3], # First message | |
| [0.9, 0.8, 0.7], # Second message (different topic) | |
| ]) | |
| now = datetime.now() | |
| messages = [ | |
| ChatMessage(id="1", content="Discussion about weather", created_at=now), | |
| ChatMessage(id="2", content="Analysis of financial reports", created_at=now), | |
| ] | |
| changes = detector.detect_topic_changes(messages) | |
| # Low similarity should trigger topic change | |
| assert len(changes) >= 1 | |
| def test_detect_topic_changes_fallback_to_keywords(self, detector): | |
| """Fallback to keyword-based similarity when embeddings fail.""" | |
| # Mock embeddings to return None (failure) | |
| detector.db.embed_text = Mock(return_value=None) | |
| now = datetime.now() | |
| messages = [ | |
| ChatMessage(id="1", content="Discussion about weather", created_at=now), | |
| ChatMessage(id="2", content="Analysis of financial reports", created_at=now), | |
| ] | |
| changes = detector.detect_topic_changes(messages) | |
| # Should use keyword similarity and detect change | |
| assert isinstance(changes, list) | |
| def test_detect_topic_changes_empty_messages(self, detector): | |
| """Handle empty message list for topic changes.""" | |
| changes = detector.detect_topic_changes([]) | |
| assert changes == [] | |
| def test_detect_topic_changes_single_message(self, detector): | |
| """Handle single message (no changes possible).""" | |
| messages = [ | |
| ChatMessage(id="1", content="First", created_at=datetime.now()) | |
| ] | |
| changes = detector.detect_topic_changes(messages) | |
| assert changes == [] | |
| # Test: cosine similarity calculation | |
| def test_cosine_similarity_identical_vectors(self, detector): | |
| """Calculate cosine similarity for identical vectors.""" | |
| vec1 = [1.0, 2.0, 3.0] | |
| vec2 = [1.0, 2.0, 3.0] | |
| similarity = detector._cosine_similarity(vec1, vec2) | |
| assert similarity == pytest.approx(1.0, rel=1e-5) | |
| def test_cosine_similarity_orthogonal_vectors(self, detector): | |
| """Calculate cosine similarity for orthogonal vectors.""" | |
| vec1 = [1.0, 0.0, 0.0] | |
| vec2 = [0.0, 1.0, 0.0] | |
| similarity = detector._cosine_similarity(vec1, vec2) | |
| assert similarity == pytest.approx(0.0, abs=1e-5) | |
| def test_cosine_similarity_zero_vectors(self, detector): | |
| """Handle zero-magnitude vectors.""" | |
| vec1 = [0.0, 0.0, 0.0] | |
| vec2 = [1.0, 2.0, 3.0] | |
| similarity = detector._cosine_similarity(vec1, vec2) | |
| assert similarity == 0.0 | |
| # Test: keyword similarity fallback | |
| def test_keyword_similarity_identical_text(self, detector): | |
| """Calculate keyword similarity for identical text.""" | |
| similarity = detector._keyword_similarity("hello world", "hello world") | |
| assert similarity == 1.0 | |
| def test_keyword_similarity_no_overlap(self, detector): | |
| """Calculate keyword similarity with no overlap.""" | |
| similarity = detector._keyword_similarity("hello world", "foo bar") | |
| assert similarity == 0.0 | |
| def test_keyword_similarity_partial_overlap(self, detector): | |
| """Calculate keyword similarity with partial overlap.""" | |
| similarity = detector._keyword_similarity("hello world test", "hello foo bar") | |
| # 1 word in common ("hello") out of 5 total | |
| assert 0.0 < similarity < 1.0 | |
| def test_keyword_similarity_empty_text(self, detector): | |
| """Handle empty text for keyword similarity.""" | |
| similarity = detector._keyword_similarity("", "hello world") | |
| assert similarity == 0.0 | |
| def test_keyword_similarity_case_insensitive(self, detector): | |
| """Keyword similarity is case-insensitive.""" | |
| sim1 = detector._keyword_similarity("Hello World", "hello world") | |
| sim2 = detector._keyword_similarity("Hello World", "HELLO WORLD") | |
| assert sim1 == sim2 == 1.0 | |
| # Test: task completion detection | |
| def test_detect_task_completion(self, detector): | |
| """Detect task completion markers.""" | |
| executions = [ | |
| AgentExecution(id="1", status="completed", result_summary="Task done"), | |
| AgentExecution(id="2", status="running", result_summary=None), | |
| AgentExecution(id="3", status="completed", result_summary="Another done"), | |
| ] | |
| completions = detector.detect_task_completion(executions) | |
| assert len(completions) == 2 | |
| assert 0 in completions | |
| assert 2 in completions | |
| def test_detect_task_completion_empty(self, detector): | |
| """Handle empty execution list.""" | |
| completions = detector.detect_task_completion([]) | |
| assert completions == [] | |
| def test_detect_task_completion_no_completions(self, detector): | |
| """Handle executions with no completed tasks.""" | |
| executions = [ | |
| AgentExecution(id="1", status="running", result_summary=None), | |
| AgentExecution(id="2", status="failed", result_summary=None), | |
| ] | |
| completions = detector.detect_task_completion(executions) | |
| assert completions == [] | |
| class TestEpisodeSegmentationServiceCoverage: | |
| """Coverage expansion for EpisodeSegmentationService.""" | |
| def db_session(self): | |
| """Get test database session.""" | |
| from core.database import SessionLocal | |
| session = SessionLocal() | |
| yield session | |
| session.rollback() | |
| session.close() | |
| def segmentation_service(self, db_session): | |
| """Get segmentation service instance.""" | |
| return EpisodeSegmentationService(db_session) | |
| # Test: time-based segmentation | |
| def test_should_segment_by_time_gap(self, segmentation_service): | |
| """Segment episodes when time gap exceeds threshold.""" | |
| last_timestamp = datetime.now() - timedelta(hours=2) | |
| current_timestamp = datetime.now() | |
| should_segment = segmentation_service.should_segment_by_time( | |
| last_timestamp=last_timestamp, | |
| current_timestamp=current_timestamp, | |
| threshold_minutes=30 | |
| ) | |
| assert should_segment == True | |
| def test_should_not_segment_small_time_gap(self, segmentation_service): | |
| """Don't segment episodes for small time gaps.""" | |
| last_timestamp = datetime.now() - timedelta(minutes=5) | |
| current_timestamp = datetime.now() | |
| should_segment = segmentation_service.should_segment_by_time( | |
| last_timestamp=last_timestamp, | |
| current_timestamp=current_timestamp, | |
| threshold_minutes=30 | |
| ) | |
| assert should_segment == False | |
| # Test: create segment | |
| def test_create_episode_segment(self, segmentation_service, db_session): | |
| """Create a new episode segment.""" | |
| from core.models import AgentEpisode | |
| episode = AgentEpisode( | |
| id="test-episode", | |
| agent_id="test-agent", | |
| workspace_id="default", | |
| status="active" | |
| ) | |
| db_session.add(episode) | |
| db_session.commit() | |
| segment = segmentation_service.create_segment( | |
| episode_id="test-episode", | |
| content="Test segment content", | |
| sequence_order=0 | |
| ) | |
| assert segment.id is not None | |
| assert segment.content == "Test segment content" | |
| assert segment.sequence_order == 0 | |
| # Test: boundary detection integration | |
| def test_detect_boundaries_integration(self, segmentation_service): | |
| """Test boundary detection with service integration.""" | |
| now = datetime.now() | |
| messages = [ | |
| ChatMessage(id="1", content="First", created_at=now - timedelta(minutes=60)), | |
| ChatMessage(id="2", content="Second", created_at=now), | |
| ] | |
| boundaries = segmentation_service.detect_boundaries(messages) | |
| assert isinstance(boundaries, list) | |
| class TestEpisodeLifecycleServiceCoverage: | |
| """Coverage expansion for EpisodeLifecycleService.""" | |
| def db_session(self): | |
| """Get test database session.""" | |
| from core.database import SessionLocal | |
| session = SessionLocal() | |
| yield session | |
| session.rollback() | |
| session.close() | |
| def lifecycle_service(self, db_session): | |
| """Get lifecycle service instance.""" | |
| from core.episode_lifecycle_service import EpisodeLifecycleService | |
| return EpisodeLifecycleService(db_session) | |
| # Test: create episode | |
| def test_create_episode(self, lifecycle_service, db_session): | |
| """Create a new episode.""" | |
| episode = lifecycle_service.create_episode( | |
| agent_id="test-agent", | |
| workspace_id="default_shared" | |
| ) | |
| assert episode.id is not None | |
| assert episode.status == "active" | |
| assert episode.agent_id == "test-agent" | |
| assert episode.workspace_id == "default_shared" | |
| # Test: complete episode | |
| def test_complete_episode(self, lifecycle_service, db_session): | |
| """Complete an active episode.""" | |
| # Create episode | |
| episode = lifecycle_service.create_episode( | |
| agent_id="test-agent", | |
| workspace_id="default_shared" | |
| ) | |
| completed = lifecycle_service.complete_episode( | |
| episode_id=episode.id, | |
| outcome="success" | |
| ) | |
| assert completed.status == "completed" | |
| assert completed.outcome == "success" | |
| assert completed.completed_at is not None | |
| # Test: archive episode | |
| def test_archive_old_episodes(self, lifecycle_service, db_session): | |
| """Archive episodes older than threshold.""" | |
| # Create old episode | |
| episode = lifecycle_service.create_episode( | |
| agent_id="test-agent", | |
| workspace_id="default_shared" | |
| ) | |
| episode.created_at = datetime.now() - timedelta(days=100) | |
| db_session.commit() | |
| archived_count = lifecycle_service.archive_episodes_older_than(days=90) | |
| assert archived_count >= 0 | |
| # Test: get episode | |
| def test_get_episode(self, lifecycle_service, db_session): | |
| """Retrieve episode by ID.""" | |
| episode = lifecycle_service.create_episode( | |
| agent_id="test-agent", | |
| workspace_id="default_shared" | |
| ) | |
| retrieved = lifecycle_service.get_episode(episode.id) | |
| assert retrieved is not None | |
| assert retrieved.id == episode.id | |
| def test_get_episode_not_found(self, lifecycle_service): | |
| """Return None for non-existent episode.""" | |
| retrieved = lifecycle_service.get_episode("nonexistent-episode") | |
| assert retrieved is None | |
| class TestAgentGraduationServiceCoverage: | |
| """Coverage expansion for AgentGraduationService.""" | |
| def db_session(self): | |
| """Get test database session.""" | |
| from core.database import SessionLocal | |
| session = SessionLocal() | |
| yield session | |
| session.rollback() | |
| session.close() | |
| def graduation_service(self, db_session): | |
| """Get graduation service instance.""" | |
| from core.agent_graduation_service import AgentGraduationService | |
| return AgentGraduationService(db_session) | |
| # Test: STUDENT -> INTERN graduation criteria | |
| def test_check_graduation_student_to_intern_met(self, graduation_service, db_session): | |
| """Check STUDENT -> INTERN graduation when criteria met.""" | |
| agent = AgentRegistry( | |
| id="test-student", | |
| name="Test Student", | |
| category="test", | |
| module_path="test", | |
| class_name="TestStudent", | |
| maturity=AgentStatus.STUDENT.value, | |
| workspace_id="default" | |
| ) | |
| db_session.add(agent) | |
| db_session.commit() | |
| # Mock episode count and intervention rate | |
| with patch.object(graduation_service, '_get_episode_count', return_value=10): | |
| with patch.object(graduation_service, '_get_intervention_rate', return_value=0.4): | |
| result = graduation_service.check_graduation_criteria(agent_id="test-student") | |
| assert result.can_graduate == True | |
| def test_check_graduation_student_to_intern_not_met(self, graduation_service, db_session): | |
| """Check STUDENT -> INTERN graduation when criteria not met.""" | |
| agent = AgentRegistry( | |
| id="test-student", | |
| name="Test Student", | |
| category="test", | |
| module_path="test", | |
| class_name="TestStudent", | |
| maturity=AgentStatus.STUDENT.value, | |
| workspace_id="default" | |
| ) | |
| db_session.add(agent) | |
| db_session.commit() | |
| # Mock insufficient episodes | |
| with patch.object(graduation_service, '_get_episode_count', return_value=5): | |
| with patch.object(graduation_service, '_get_intervention_rate', return_value=0.4): | |
| result = graduation_service.check_graduation_criteria(agent_id="test-student") | |
| assert result.can_graduate == False | |
| # Test: INTERN -> SUPERVISED graduation criteria | |
| def test_check_graduation_intern_to_supervised_met(self, graduation_service, db_session): | |
| """Check INTERN -> SUPERVISED graduation when criteria met.""" | |
| agent = AgentRegistry( | |
| id="test-intern", | |
| name="Test Intern", | |
| category="test", | |
| module_path="test", | |
| class_name="TestIntern", | |
| maturity=AgentStatus.INTERN.value, | |
| workspace_id="default" | |
| ) | |
| db_session.add(agent) | |
| db_session.commit() | |
| with patch.object(graduation_service, '_get_episode_count', return_value=25): | |
| with patch.object(graduation_service, '_get_intervention_rate', return_value=0.15): | |
| result = graduation_service.check_graduation_criteria(agent_id="test-intern") | |
| assert result.can_graduate == True | |
| # Test: execute graduation | |
| def test_execute_graduation(self, graduation_service, db_session): | |
| """Execute agent graduation.""" | |
| agent = AgentRegistry( | |
| id="test-promote", | |
| name="Test Promote", | |
| category="test", | |
| module_path="test", | |
| class_name="TestPromote", | |
| maturity=AgentStatus.STUDENT.value, | |
| workspace_id="default" | |
| ) | |
| db_session.add(agent) | |
| db_session.commit() | |
| with patch.object(graduation_service, 'check_graduation_criteria') as mock_check: | |
| mock_check.return_value = Mock( | |
| can_graduate=True, | |
| target_maturity=AgentStatus.INTERN | |
| ) | |
| graduated = graduation_service.execute_graduation(agent_id="test-promote") | |
| assert graduated.maturity == AgentStatus.INTERN.value | |
| def test_execute_graduation_not_eligible(self, graduation_service, db_session): | |
| """Execute graduation fails when not eligible.""" | |
| agent = AgentRegistry( | |
| id="test-no-promote", | |
| name="Test No Promote", | |
| category="test", | |
| module_path="test", | |
| class_name="TestNoPromote", | |
| maturity=AgentStatus.STUDENT.value, | |
| workspace_id="default" | |
| ) | |
| db_session.add(agent) | |
| db_session.commit() | |
| with patch.object(graduation_service, 'check_graduation_criteria') as mock_check: | |
| mock_check.return_value = Mock( | |
| can_graduate=False, | |
| target_maturity=None | |
| ) | |
| # Should not graduate | |
| result = graduation_service.execute_graduation(agent_id="test-no-promote") | |
| assert result.maturity == AgentStatus.STUDENT.value | |