From 2b883dd35fc3595eb92a9b9afce21aec286261ea Mon Sep 17 00:00:00 2001 From: kimi Date: Sat, 21 Mar 2026 09:44:36 -0400 Subject: [PATCH] feat: add agent_state message producer for Matrix frontend Add produce_agent_state() to infrastructure/presence that converts ADR-023 presence dicts into Matrix-compatible agent_state messages with type, agent_id, data (display_name, role, status, mood, energy, bark), and Unix timestamp. Includes status derivation from current_focus (thinking/speaking/idle/online) and comprehensive tests covering all fields, defaults, and status mapping. Fixes #669 Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/presence.py | 56 ++++++++++++++++++++++ tests/unit/test_presence.py | 87 +++++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/infrastructure/presence.py b/src/infrastructure/presence.py index 3f6dde87..bdc6e8b3 100644 --- a/src/infrastructure/presence.py +++ b/src/infrastructure/presence.py @@ -5,6 +5,7 @@ into the camelCase world-state payload consumed by the Workshop 3D renderer and WebSocket gateway. """ +import time from datetime import UTC, datetime @@ -40,3 +41,58 @@ def serialize_presence(presence: dict) -> dict: "updatedAt": presence.get("liveness", datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")), "version": presence.get("version", 1), } + + +# --------------------------------------------------------------------------- +# Status mapping: ADR-023 current_focus → Matrix agent status +# --------------------------------------------------------------------------- +_STATUS_KEYWORDS: dict[str, str] = { + "thinking": "thinking", + "speaking": "speaking", + "talking": "speaking", + "idle": "idle", +} + + +def _derive_status(current_focus: str) -> str: + """Map a free-text current_focus value to a Matrix status enum. + + Returns one of: online, idle, thinking, speaking. + """ + focus_lower = current_focus.lower() + for keyword, status in _STATUS_KEYWORDS.items(): + if keyword in focus_lower: + return status + if current_focus and current_focus != "idle": + return "online" + return "idle" + + +def produce_agent_state(agent_id: str, presence: dict) -> dict: + """Build a Matrix-compatible ``agent_state`` message from presence data. + + Parameters + ---------- + agent_id: + Unique identifier for the agent (e.g. ``"timmy"``). + presence: + Raw ADR-023 presence dict. + + Returns + ------- + dict + Message with keys ``type``, ``agent_id``, ``data``, and ``ts``. + """ + return { + "type": "agent_state", + "agent_id": agent_id, + "data": { + "display_name": presence.get("display_name", agent_id.title()), + "role": presence.get("role", "assistant"), + "status": _derive_status(presence.get("current_focus", "idle")), + "mood": presence.get("mood", "calm"), + "energy": presence.get("energy", 0.5), + "bark": presence.get("bark", ""), + }, + "ts": int(time.time()), + } diff --git a/tests/unit/test_presence.py b/tests/unit/test_presence.py index d6ff7d5b..cb5f7d8d 100644 --- a/tests/unit/test_presence.py +++ b/tests/unit/test_presence.py @@ -1,8 +1,10 @@ """Tests for infrastructure.presence — presence state serializer.""" +from unittest.mock import patch + import pytest -from infrastructure.presence import serialize_presence +from infrastructure.presence import produce_agent_state, serialize_presence class TestSerializePresence: @@ -78,3 +80,86 @@ class TestSerializePresence: """visitorPresent is always False — set by the WS layer, not here.""" assert serialize_presence(full_presence)["visitorPresent"] is False assert serialize_presence({})["visitorPresent"] is False + + +class TestProduceAgentState: + """Tests for produce_agent_state() — Matrix agent_state message producer.""" + + @pytest.fixture() + def full_presence(self): + """A presence dict with all agent_state-relevant fields.""" + return { + "display_name": "Timmy", + "role": "companion", + "current_focus": "thinking about tests", + "mood": "focused", + "energy": 0.9, + "bark": "Running test suite...", + } + + @patch("infrastructure.presence.time") + def test_full_message_structure(self, mock_time, full_presence): + """Returns dict with type, agent_id, data, and ts keys.""" + mock_time.time.return_value = 1742529600 + result = produce_agent_state("timmy", full_presence) + + assert result["type"] == "agent_state" + assert result["agent_id"] == "timmy" + assert result["ts"] == 1742529600 + assert isinstance(result["data"], dict) + + def test_data_fields(self, full_presence): + """data dict contains all required presence fields.""" + data = produce_agent_state("timmy", full_presence)["data"] + + assert data["display_name"] == "Timmy" + assert data["role"] == "companion" + assert data["status"] == "thinking" + assert data["mood"] == "focused" + assert data["energy"] == 0.9 + assert data["bark"] == "Running test suite..." + + def test_defaults_on_empty_presence(self): + """Missing fields get sensible defaults.""" + result = produce_agent_state("timmy", {}) + data = result["data"] + + assert data["display_name"] == "Timmy" # agent_id.title() + assert data["role"] == "assistant" + assert data["status"] == "idle" + assert data["mood"] == "calm" + assert data["energy"] == 0.5 + assert data["bark"] == "" + + def test_ts_is_unix_timestamp(self): + """ts should be an integer Unix timestamp.""" + result = produce_agent_state("timmy", {}) + assert isinstance(result["ts"], int) + assert result["ts"] > 0 + + @pytest.mark.parametrize( + ("focus", "expected_status"), + [ + ("thinking about code", "thinking"), + ("speaking to user", "speaking"), + ("talking with agent", "speaking"), + ("idle", "idle"), + ("", "idle"), + ("writing tests", "online"), + ("reviewing PR", "online"), + ], + ) + def test_status_derivation(self, focus, expected_status): + """current_focus maps to the correct Matrix status.""" + data = produce_agent_state("t", {"current_focus": focus})["data"] + assert data["status"] == expected_status + + def test_agent_id_passed_through(self): + """agent_id appears in the top-level message.""" + result = produce_agent_state("spark", {}) + assert result["agent_id"] == "spark" + + def test_display_name_from_agent_id(self): + """When display_name is missing, it's derived from agent_id.title().""" + data = produce_agent_state("spark", {})["data"] + assert data["display_name"] == "Spark" -- 2.43.0