diff --git a/src/infrastructure/presence.py b/src/infrastructure/presence.py index bdc6e8b..82c5e35 100644 --- a/src/infrastructure/presence.py +++ b/src/infrastructure/presence.py @@ -8,6 +8,62 @@ and WebSocket gateway. import time from datetime import UTC, datetime +# Valid bark styles for Matrix protocol +BARK_STYLES = {"speech", "thought", "whisper", "shout"} + + +def produce_bark(agent_id: str, text: str, reply_to: str = None, style: str = "speech") -> dict: + """Format a chat response as a Matrix bark message. + + Barks appear as floating text above agents in the Matrix 3D world with + typing animation. This function formats the text for the Matrix protocol. + + Parameters + ---------- + agent_id: + Unique identifier for the agent (e.g. ``"timmy"``). + text: + The chat response text to display as a bark. + reply_to: + Optional message ID or reference this bark is replying to. + style: + Visual style of the bark. One of: "speech" (default), "thought", + "whisper", "shout". Invalid styles fall back to "speech". + + Returns + ------- + dict + Bark message with keys ``type``, ``agent_id``, ``data`` (containing + ``text``, ``reply_to``, ``style``), and ``ts``. + + Examples + -------- + >>> produce_bark("timmy", "Hello world!") + { + "type": "bark", + "agent_id": "timmy", + "data": {"text": "Hello world!", "reply_to": None, "style": "speech"}, + "ts": 1742529600, + } + """ + # Validate and normalize style + if style not in BARK_STYLES: + style = "speech" + + # Truncate text to 280 characters (bark, not essay) + truncated_text = text[:280] if text else "" + + return { + "type": "bark", + "agent_id": agent_id, + "data": { + "text": truncated_text, + "reply_to": reply_to, + "style": style, + }, + "ts": int(time.time()), + } + def serialize_presence(presence: dict) -> dict: """Transform an ADR-023 presence dict into the world-state API shape. diff --git a/tests/unit/test_presence.py b/tests/unit/test_presence.py index cb5f7d8..4e308de 100644 --- a/tests/unit/test_presence.py +++ b/tests/unit/test_presence.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from infrastructure.presence import produce_agent_state, serialize_presence +from infrastructure.presence import produce_agent_state, produce_bark, serialize_presence class TestSerializePresence: @@ -163,3 +163,109 @@ class TestProduceAgentState: """When display_name is missing, it's derived from agent_id.title().""" data = produce_agent_state("spark", {})["data"] assert data["display_name"] == "Spark" + + +class TestProduceBark: + """Tests for produce_bark() — Matrix bark message producer.""" + + @patch("infrastructure.presence.time") + def test_full_message_structure(self, mock_time): + """Returns dict with type, agent_id, data, and ts keys.""" + mock_time.time.return_value = 1742529600 + result = produce_bark("timmy", "Hello world!") + + assert result["type"] == "bark" + assert result["agent_id"] == "timmy" + assert result["ts"] == 1742529600 + assert isinstance(result["data"], dict) + + def test_data_fields(self): + """data dict contains text, reply_to, and style.""" + result = produce_bark("timmy", "Hello world!", reply_to="msg-123", style="shout") + data = result["data"] + + assert data["text"] == "Hello world!" + assert data["reply_to"] == "msg-123" + assert data["style"] == "shout" + + def test_default_style_is_speech(self): + """When style is not provided, defaults to 'speech'.""" + result = produce_bark("timmy", "Hello!") + assert result["data"]["style"] == "speech" + + def test_default_reply_to_is_none(self): + """When reply_to is not provided, defaults to None.""" + result = produce_bark("timmy", "Hello!") + assert result["data"]["reply_to"] is None + + def test_text_truncated_to_280_chars(self): + """Text longer than 280 chars is truncated.""" + long_text = "A" * 500 + result = produce_bark("timmy", long_text) + assert len(result["data"]["text"]) == 280 + assert result["data"]["text"] == "A" * 280 + + def test_text_exactly_280_chars_not_truncated(self): + """Text exactly 280 chars is not truncated.""" + text = "B" * 280 + result = produce_bark("timmy", text) + assert result["data"]["text"] == text + + def test_text_shorter_than_280_not_padded(self): + """Text shorter than 280 chars is not padded.""" + result = produce_bark("timmy", "Short") + assert result["data"]["text"] == "Short" + + @pytest.mark.parametrize( + ("style", "expected_style"), + [ + ("speech", "speech"), + ("thought", "thought"), + ("whisper", "whisper"), + ("shout", "shout"), + ], + ) + def test_valid_styles_preserved(self, style, expected_style): + """Valid style values are preserved.""" + result = produce_bark("timmy", "Hello!", style=style) + assert result["data"]["style"] == expected_style + + @pytest.mark.parametrize( + "invalid_style", + ["yell", "scream", "", "SPEECH", "Speech", None, 123], + ) + def test_invalid_style_defaults_to_speech(self, invalid_style): + """Invalid style values fall back to 'speech'.""" + result = produce_bark("timmy", "Hello!", style=invalid_style) + assert result["data"]["style"] == "speech" + + def test_empty_text_handled(self): + """Empty text is handled gracefully.""" + result = produce_bark("timmy", "") + assert result["data"]["text"] == "" + + def test_ts_is_unix_timestamp(self): + """ts should be an integer Unix timestamp.""" + result = produce_bark("timmy", "Hello!") + assert isinstance(result["ts"], int) + assert result["ts"] > 0 + + def test_agent_id_passed_through(self): + """agent_id appears in the top-level message.""" + result = produce_bark("spark", "Hello!") + assert result["agent_id"] == "spark" + + def test_with_all_parameters(self): + """Full parameter set produces expected output.""" + result = produce_bark( + agent_id="timmy", + text="Running test suite...", + reply_to="parent-msg-456", + style="thought", + ) + + assert result["type"] == "bark" + assert result["agent_id"] == "timmy" + assert result["data"]["text"] == "Running test suite..." + assert result["data"]["reply_to"] == "parent-msg-456" + assert result["data"]["style"] == "thought"