feat: broadcast Timmy state changes via WS relay (#380)

Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
2026-03-19 00:25:11 -04:00
committed by hermes
parent aa4f1de138
commit da43421d4e
5 changed files with 171 additions and 16 deletions

View File

@@ -1,8 +1,8 @@
"""Tests for GET /api/world/state endpoint."""
"""Tests for GET /api/world/state endpoint and /api/world/ws relay."""
import json
import time
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -10,6 +10,7 @@ from dashboard.routes.world import (
_STALE_THRESHOLD,
_build_world_state,
_read_presence_file,
broadcast_world_state,
)
# ---------------------------------------------------------------------------
@@ -166,3 +167,55 @@ def test_world_state_endpoint_full_fallback(client, tmp_path):
data = resp.json()
assert data["timmyState"]["mood"] == "idle"
assert data["version"] == 1
# ---------------------------------------------------------------------------
# broadcast_world_state
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_broadcast_world_state_sends_timmy_state():
"""broadcast_world_state sends timmy_state JSON to connected clients."""
from dashboard.routes.world import _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
try:
presence = {
"version": 1,
"mood": "exploring",
"current_focus": "testing",
"energy": 0.8,
"confidence": 0.9,
}
await broadcast_world_state(presence)
ws.send_text.assert_called_once()
msg = json.loads(ws.send_text.call_args[0][0])
assert msg["type"] == "timmy_state"
assert msg["mood"] == "exploring"
assert msg["activity"] == "testing"
finally:
_ws_clients.clear()
@pytest.mark.asyncio
async def test_broadcast_world_state_removes_dead_clients():
"""Dead WebSocket connections are cleaned up on broadcast."""
from dashboard.routes.world import _ws_clients
dead_ws = AsyncMock()
dead_ws.send_text.side_effect = ConnectionError("gone")
_ws_clients.append(dead_ws)
try:
await broadcast_world_state({"mood": "idle"})
assert dead_ws not in _ws_clients
finally:
_ws_clients.clear()
def test_world_ws_endpoint_accepts_connection(client):
"""WebSocket endpoint at /api/world/ws accepts connections."""
with client.websocket_connect("/api/world/ws"):
pass # Connection accepted — just close it

View File

@@ -98,7 +98,8 @@ def test_state_hash_detects_mood_change():
# ---------------------------------------------------------------------------
def test_write_if_changed_writes_on_first_call(tmp_path):
@pytest.mark.asyncio
async def test_write_if_changed_writes_on_first_call(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
@@ -109,7 +110,7 @@ def test_write_if_changed_writes_on_first_call(tmp_path):
"current_focus": "testing",
"mood": "focused",
}
hb._write_if_changed()
await hb._write_if_changed()
assert target.exists()
data = json.loads(target.read_text())
@@ -117,22 +118,24 @@ def test_write_if_changed_writes_on_first_call(tmp_path):
assert data["current_focus"] == "testing"
def test_write_if_changed_skips_when_unchanged(tmp_path):
@pytest.mark.asyncio
async def test_write_if_changed_skips_when_unchanged(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
fixed_state = {"version": 1, "liveness": "t1", "mood": "idle"}
with patch("timmy.workshop_state.get_state_dict", return_value=fixed_state):
hb._write_if_changed() # First write
await hb._write_if_changed() # First write
target.write_text("") # Clear to detect if second write happens
hb._write_if_changed() # Should skip — state unchanged
await hb._write_if_changed() # Should skip — state unchanged
# File should still be empty (second write was skipped)
assert target.read_text() == ""
def test_write_if_changed_writes_on_state_change(tmp_path):
@pytest.mark.asyncio
async def test_write_if_changed_writes_on_state_change(tmp_path):
target = tmp_path / "presence.json"
hb = WorkshopHeartbeat(interval=60, path=target)
@@ -140,15 +143,54 @@ def test_write_if_changed_writes_on_state_change(tmp_path):
state_b = {"version": 1, "liveness": "t2", "mood": "focused"}
with patch("timmy.workshop_state.get_state_dict", return_value=state_a):
hb._write_if_changed()
await hb._write_if_changed()
with patch("timmy.workshop_state.get_state_dict", return_value=state_b):
hb._write_if_changed()
await hb._write_if_changed()
data = json.loads(target.read_text())
assert data["mood"] == "focused"
@pytest.mark.asyncio
async def test_write_if_changed_calls_on_change(tmp_path):
"""on_change callback is invoked with state dict when state changes."""
target = tmp_path / "presence.json"
received = []
async def capture(state_dict):
received.append(state_dict)
hb = WorkshopHeartbeat(interval=60, path=target, on_change=capture)
state = {"version": 1, "liveness": "t1", "mood": "focused"}
with patch("timmy.workshop_state.get_state_dict", return_value=state):
await hb._write_if_changed()
assert len(received) == 1
assert received[0]["mood"] == "focused"
@pytest.mark.asyncio
async def test_write_if_changed_skips_on_change_when_unchanged(tmp_path):
"""on_change is NOT called when state hash is unchanged."""
target = tmp_path / "presence.json"
call_count = 0
async def counter(_):
nonlocal call_count
call_count += 1
hb = WorkshopHeartbeat(interval=60, path=target, on_change=counter)
state = {"version": 1, "liveness": "t1", "mood": "idle"}
with patch("timmy.workshop_state.get_state_dict", return_value=state):
await hb._write_if_changed()
await hb._write_if_changed()
assert call_count == 1
# ---------------------------------------------------------------------------
# WorkshopHeartbeat — notify
# ---------------------------------------------------------------------------