fix: wire cognitive state to sensory bus (presence loop) (#414)
Some checks failed
Tests / lint (push) Failing after 17m22s
Tests / test (push) Has been skipped

## Summary
- CognitiveTracker.update() now emits `cognitive_state_changed` events to the SensoryBus
- WorkshopHeartbeat (and other subscribers) react immediately to mood/engagement changes
- Closes the sense → memory → react loop described in the Workshop architecture
- Fire-and-forget emission — never blocks the chat response path
- Gracefully skips when no event loop is running (sync contexts/tests)

## Test plan
- [x] 3 new tests: event emission, mood change tracking, graceful skip without loop
- [x] All 1935 unit tests pass
- [x] Lint + format clean

Fixes #222

Co-authored-by: kimi <kimi@localhost>
Reviewed-on: #414
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit was merged in pull request #414.
This commit is contained in:
2026-03-19 03:23:03 -04:00
committed by rockachopa
parent 76b26ead55
commit 332fa373b8
2 changed files with 89 additions and 0 deletions

View File

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

View File

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