fix: wire cognitive state to sensory bus (presence loop) #414
@@ -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
|
||||
|
||||
@@ -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!")
|
||||
|
||||
Reference in New Issue
Block a user