"""Regression tests for dream scheduler bug fixes.""" from typing import Any from unittest.mock import AsyncMock, patch import pytest from src.dreamer.dream_scheduler import DreamScheduler, set_dream_scheduler from src.schemas import DreamType from src.utils.work_unit import construct_work_unit_key @pytest.fixture def dream_scheduler(): """Create a fresh DreamScheduler instance for each test.""" # Reset the singleton before each test DreamScheduler.reset_singleton() scheduler = DreamScheduler() set_dream_scheduler(scheduler) # Patch DREAM.ENABLED to True so tests work regardless of local config with patch("src.dreamer.dream_scheduler.settings.DREAM.ENABLED", True): yield scheduler # Cleanup DreamScheduler.reset_singleton() class TestCancelDreamsForObserved: """Regression tests for Bug #1: Peer-to-peer observation dreams not cancelled on activity. Previously, when a message arrived from peer Bob, only the self-observation dream (observer=Bob, observed=Bob) was cancelled. Peer-to-peer observation dreams (observer=Alice, observed=Bob) were NOT cancelled, allowing dreams to fire during active conversation. """ @pytest.mark.asyncio async def test_cancel_self_observation_dream(self, dream_scheduler: DreamScheduler): """Cancelling dreams for observed peer should cancel self-observation dreams.""" workspace_name = "test_workspace" peer_name = "bob" # Schedule a self-observation dream (observer=bob, observed=bob) work_unit_key = construct_work_unit_key( workspace_name, { "task_type": "dream", "observer": peer_name, "observed": peer_name, "dream_type": "omni", }, ) with patch.object(dream_scheduler, "execute_dream", new_callable=AsyncMock): await dream_scheduler.schedule_dream( work_unit_key, workspace_name, delay_minutes=60, dream_type=DreamType.OMNI, observer=peer_name, observed=peer_name, ) # Verify dream is pending assert work_unit_key in dream_scheduler.pending_dreams # Cancel dreams for observed peer cancelled = await dream_scheduler.cancel_dreams_for_observed( workspace_name, peer_name ) # Verify the dream was cancelled assert work_unit_key in cancelled assert work_unit_key not in dream_scheduler.pending_dreams @pytest.mark.asyncio async def test_cancel_peer_to_peer_observation_dream( self, dream_scheduler: DreamScheduler ): """Cancelling dreams for observed peer should also cancel peer-to-peer dreams. This is the core regression test for Bug #1: previously, if Alice was observing Bob and Bob sent a message, the dream for (observer=Alice, observed=Bob) would NOT be cancelled. """ workspace_name = "test_workspace" observer = "alice" # Alice is watching Bob observed = "bob" # Bob sends a message # Schedule a peer-to-peer observation dream (observer=alice, observed=bob) work_unit_key = construct_work_unit_key( workspace_name, { "task_type": "dream", "observer": observer, "observed": observed, "dream_type": "omni", }, ) with patch.object(dream_scheduler, "execute_dream", new_callable=AsyncMock): await dream_scheduler.schedule_dream( work_unit_key, workspace_name, delay_minutes=60, dream_type=DreamType.OMNI, observer=observer, observed=observed, ) # Verify dream is pending assert work_unit_key in dream_scheduler.pending_dreams # When Bob sends a message, cancel all dreams where observed=bob cancelled = await dream_scheduler.cancel_dreams_for_observed( workspace_name, observed ) # Verify the peer-to-peer dream was cancelled assert work_unit_key in cancelled assert work_unit_key not in dream_scheduler.pending_dreams @pytest.mark.asyncio async def test_cancel_multiple_observers_same_observed( self, dream_scheduler: DreamScheduler ): """When observed peer sends a message, ALL dreams observing them should cancel.""" workspace_name = "test_workspace" observed = "bob" observers = ["alice", "charlie", "bob"] # Multiple observers including self work_unit_keys: list[str] = [] with patch.object(dream_scheduler, "execute_dream", new_callable=AsyncMock): for observer in observers: work_unit_key = construct_work_unit_key( workspace_name, { "task_type": "dream", "observer": observer, "observed": observed, "dream_type": "omni", }, ) work_unit_keys.append(work_unit_key) await dream_scheduler.schedule_dream( work_unit_key, workspace_name, delay_minutes=60, dream_type=DreamType.OMNI, observer=observer, observed=observed, ) # Verify all dreams are pending assert len(dream_scheduler.pending_dreams) == 3 # Cancel all dreams where observed=bob cancelled = await dream_scheduler.cancel_dreams_for_observed( workspace_name, observed ) # All three should be cancelled assert len(cancelled) == 3 for key in work_unit_keys: assert key in cancelled assert len(dream_scheduler.pending_dreams) == 0 @pytest.mark.asyncio async def test_does_not_cancel_dreams_for_different_observed( self, dream_scheduler: DreamScheduler ): """Cancelling dreams for one observed peer should not affect others.""" workspace_name = "test_workspace" # Dream for Alice observing Bob key_alice_bob = construct_work_unit_key( workspace_name, { "task_type": "dream", "observer": "alice", "observed": "bob", "dream_type": "omni", }, ) # Dream for Alice observing Charlie (should NOT be cancelled) key_alice_charlie = construct_work_unit_key( workspace_name, { "task_type": "dream", "observer": "alice", "observed": "charlie", "dream_type": "omni", }, ) with patch.object(dream_scheduler, "execute_dream", new_callable=AsyncMock): await dream_scheduler.schedule_dream( key_alice_bob, workspace_name, delay_minutes=60, dream_type=DreamType.OMNI, observer="alice", observed="bob", ) await dream_scheduler.schedule_dream( key_alice_charlie, workspace_name, delay_minutes=60, dream_type=DreamType.OMNI, observer="alice", observed="charlie", ) assert len(dream_scheduler.pending_dreams) == 2 # Cancel only dreams where observed=bob cancelled = await dream_scheduler.cancel_dreams_for_observed( workspace_name, "bob" ) # Only the bob dream should be cancelled assert key_alice_bob in cancelled assert key_alice_charlie not in cancelled assert key_alice_charlie in dream_scheduler.pending_dreams @pytest.mark.asyncio async def test_does_not_cancel_dreams_for_different_workspace( self, dream_scheduler: DreamScheduler ): """Cancelling dreams should be scoped to the correct workspace.""" observed = "bob" key_ws1 = construct_work_unit_key( "workspace1", { "task_type": "dream", "observer": "alice", "observed": observed, "dream_type": "omni", }, ) key_ws2 = construct_work_unit_key( "workspace2", { "task_type": "dream", "observer": "alice", "observed": observed, "dream_type": "omni", }, ) with patch.object(dream_scheduler, "execute_dream", new_callable=AsyncMock): await dream_scheduler.schedule_dream( key_ws1, "workspace1", delay_minutes=60, dream_type=DreamType.OMNI, observer="alice", observed=observed, ) await dream_scheduler.schedule_dream( key_ws2, "workspace2", delay_minutes=60, dream_type=DreamType.OMNI, observer="alice", observed=observed, ) # Cancel only in workspace1 cancelled = await dream_scheduler.cancel_dreams_for_observed( "workspace1", observed ) assert key_ws1 in cancelled assert key_ws2 not in cancelled assert key_ws2 in dream_scheduler.pending_dreams class TestDocumentCountAtExecutionTime: """Regression tests for Bug #2: Stale document count used in metadata update. Previously, the document count was captured when the dream was scheduled (at check_and_schedule_dream time), then used 60 minutes later when the dream actually executed. This caused incorrect metadata if documents were added during the wait period. Now, execute_dream queries the current document count at execution time. """ @pytest.mark.asyncio async def test_execute_dream_queries_document_count_at_execution( self, dream_scheduler: DreamScheduler ): """execute_dream should query current document count, not use a stale value. This test verifies that execute_dream fetches the document count fresh from the database at execution time rather than using a pre-captured value. The key architectural change was: - OLD: schedule_dream(document_count) -> _delayed_dream(document_count) -> execute_dream(document_count) - NEW: schedule_dream() -> _delayed_dream() -> execute_dream() queries count internally We verify this by mocking the database to return a specific count and checking that enqueue_dream receives that count. """ from contextlib import asynccontextmanager from unittest.mock import MagicMock from src import models from src.schemas import ( ResolvedConfiguration, ResolvedDreamConfiguration, ResolvedPeerCardConfiguration, ResolvedReasoningConfiguration, ResolvedSummaryConfiguration, ) workspace_name = "test_workspace" observer = "bob" observed = "bob" session_name = "test_session" # The document count that the database will return CURRENT_DOC_COUNT = 42 # Track what document_count is passed to enqueue_dream captured_document_count: int | None = None async def capture_enqueue_dream( _ws_name: str, observer: str, # pyright: ignore[reportUnusedParameter] observed: str, # pyright: ignore[reportUnusedParameter] dream_type: Any, # pyright: ignore[reportUnusedParameter] document_count: int, session_name: str, # pyright: ignore[reportUnusedParameter] ) -> None: nonlocal captured_document_count captured_document_count = document_count # Create mock database session that returns our test data mock_session = MagicMock() mock_workspace = MagicMock(spec=models.Workspace) mock_db_session = MagicMock(spec=models.Session) # Mock scalar to return session_name for first call, document count for second scalar_call_count = 0 async def mock_scalar(_stmt: Any) -> str | int: nonlocal scalar_call_count scalar_call_count += 1 if scalar_call_count == 1: return session_name # First call gets session_name from documents else: return CURRENT_DOC_COUNT # Second call gets document count mock_session.scalar = mock_scalar @asynccontextmanager async def mock_tracked_db(_: str | None = None): yield mock_session with ( patch( "src.dreamer.dream_scheduler.tracked_db", mock_tracked_db, ), patch( "src.deriver.enqueue.enqueue_dream", side_effect=capture_enqueue_dream, ), patch( "src.crud.get_session", return_value=mock_db_session, ), patch( "src.crud.get_workspace", return_value=mock_workspace, ), patch( "src.utils.config_helpers.get_configuration", return_value=ResolvedConfiguration( reasoning=ResolvedReasoningConfiguration(enabled=True), peer_card=ResolvedPeerCardConfiguration(use=True, create=True), summary=ResolvedSummaryConfiguration( enabled=True, messages_per_short_summary=10, messages_per_long_summary=20, ), dream=ResolvedDreamConfiguration(enabled=True), ), ), ): # Execute the dream await dream_scheduler.execute_dream( workspace_name, DreamType.OMNI, observer=observer, observed=observed, ) # Verify that execute_dream queried the document count (2 scalar calls) assert ( scalar_call_count == 2 ), "Should have queried session_name and document count" # Verify that enqueue_dream received the CURRENT document count (42), # proving that execute_dream queries the count at execution time assert captured_document_count == CURRENT_DOC_COUNT class TestEnqueueCancelsDreamsCorrectly: """Integration test verifying the full flow of message enqueue cancelling dreams.""" @pytest.mark.asyncio async def test_enqueue_cancels_peer_to_peer_dreams( self, dream_scheduler: DreamScheduler ): """When a message is enqueued, it should cancel all dreams for that observed peer.""" workspace_name = "test_workspace" observed = "bob" # Schedule dreams for multiple observers watching bob keys: list[Any] = [] for observer in ["alice", "charlie", "bob"]: key = construct_work_unit_key( workspace_name, { "task_type": "dream", "observer": observer, "observed": observed, "dream_type": "omni", }, ) keys.append(key) with patch.object(dream_scheduler, "execute_dream", new_callable=AsyncMock): await dream_scheduler.schedule_dream( key, workspace_name, delay_minutes=60, dream_type=DreamType.OMNI, observer=observer, observed=observed, ) assert len(dream_scheduler.pending_dreams) == 3 # Mock the database operations in enqueue with patch("src.deriver.enqueue.tracked_db"): # The enqueue function should cancel dreams via cancel_dreams_for_observed # We just test that the scheduler method was called correctly cancelled = await dream_scheduler.cancel_dreams_for_observed( workspace_name, observed ) # All three dreams should be cancelled assert len(cancelled) == 3 assert len(dream_scheduler.pending_dreams) == 0