diff --git a/src/timmy/cognitive_state.py b/src/timmy/cognitive_state.py index 2ec2450..f88a8b0 100644 --- a/src/timmy/cognitive_state.py +++ b/src/timmy/cognitive_state.py @@ -9,6 +9,7 @@ WebSocket relay. The old ``~/.tower/timmy-state.txt`` file has been deprecated (see #384). """ +import asyncio import json import logging from dataclasses import asdict, dataclass, field @@ -169,9 +170,14 @@ class CognitiveTracker: """Update cognitive state from a chat exchange. Called after each chat round-trip in ``session.py``. + Emits a ``cognitive_state_changed`` event to the sensory bus so + downstream consumers (WorkshopHeartbeat, etc.) react immediately. """ confidence = estimate_confidence(response) + prev_mood = self.state.mood + prev_engagement = self.state.engagement + # Track running confidence average self.state._confidence_sum += confidence self.state._confidence_count += 1 @@ -193,8 +199,40 @@ class CognitiveTracker: seen.add(c) self.state.active_commitments = self.state.active_commitments[-5:] + # Emit cognitive_state_changed to close the sense → react loop + self._emit_change(prev_mood, prev_engagement) + return self.state + def _emit_change(self, prev_mood: str, prev_engagement: str) -> None: + """Fire-and-forget sensory event for cognitive state change.""" + try: + from timmy.event_bus import get_sensory_bus + from timmy.events import SensoryEvent + + event = SensoryEvent( + source="cognitive", + event_type="cognitive_state_changed", + data={ + "mood": self.state.mood, + "engagement": self.state.engagement, + "focus_topic": self.state.focus_topic or "", + "depth": self.state.conversation_depth, + "mood_changed": self.state.mood != prev_mood, + "engagement_changed": self.state.engagement != prev_engagement, + }, + ) + bus = get_sensory_bus() + # Fire-and-forget — don't block the chat response + try: + loop = asyncio.get_running_loop() + loop.create_task(bus.emit(event)) + except RuntimeError: + # No running loop (sync context / tests) — skip emission + pass + except Exception as exc: + logger.debug("Cognitive event emission skipped: %s", exc) + def get_state(self) -> CognitiveState: """Return current cognitive state.""" return self.state diff --git a/tests/timmy/test_cognitive_state.py b/tests/timmy/test_cognitive_state.py index 2b1596a..0652561 100644 --- a/tests/timmy/test_cognitive_state.py +++ b/tests/timmy/test_cognitive_state.py @@ -1,5 +1,8 @@ """Tests for cognitive state tracking in src/timmy/cognitive_state.py.""" +import asyncio +from unittest.mock import patch + from timmy.cognitive_state import ( ENGAGEMENT_LEVELS, MOOD_VALUES, @@ -166,3 +169,51 @@ class TestCognitiveTracker: def test_engagement_values_are_valid(self): for level in ENGAGEMENT_LEVELS: assert isinstance(level, str) + + async def test_update_emits_cognitive_state_changed(self): + """CognitiveTracker.update() emits a sensory event.""" + from timmy.event_bus import SensoryBus + + mock_bus = SensoryBus() + received = [] + mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e)) + + with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus): + tracker = CognitiveTracker() + tracker.update("debug the memory leak", "Looking at the stack trace now.") + # Give the fire-and-forget task a chance to run + await asyncio.sleep(0.05) + + assert len(received) == 1 + event = received[0] + assert event.source == "cognitive" + assert event.event_type == "cognitive_state_changed" + assert "mood" in event.data + assert "engagement" in event.data + assert "depth" in event.data + assert event.data["depth"] == 1 + + async def test_update_tracks_mood_change(self): + """Event data includes whether mood/engagement changed.""" + from timmy.event_bus import SensoryBus + + mock_bus = SensoryBus() + received = [] + mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e)) + + with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus): + tracker = CognitiveTracker() + # First message — "!" + "great" with high confidence → "energized" + tracker.update("wow", "That's a great discovery!") + await asyncio.sleep(0.05) + + assert len(received) == 1 + # Default mood is "settled", energized response → mood changes + assert received[0].data["mood"] == "energized" + assert received[0].data["mood_changed"] is True + + def test_emit_skipped_without_event_loop(self): + """Event emission gracefully skips when no async loop is running.""" + tracker = CognitiveTracker() + # Should not raise — just silently skips + tracker.update("hello", "Hi there!")