diff --git a/src/dashboard/routes/world.py b/src/dashboard/routes/world.py index 77a7a9d9..5e177915 100644 --- a/src/dashboard/routes/world.py +++ b/src/dashboard/routes/world.py @@ -697,3 +697,73 @@ async def get_matrix_agents() -> JSONResponse: content=agents, headers={"Cache-Control": "no-cache, no-store"}, ) + + +# --------------------------------------------------------------------------- +# Matrix Thoughts Endpoint — Timmy's recent thought stream for Matrix display +# --------------------------------------------------------------------------- + +_MAX_THOUGHT_LIMIT = 50 # Maximum thoughts allowed per request +_DEFAULT_THOUGHT_LIMIT = 10 # Default number of thoughts to return +_MAX_THOUGHT_TEXT_LEN = 500 # Max characters for thought text + + +def _build_matrix_thoughts_response(limit: int = _DEFAULT_THOUGHT_LIMIT) -> list[dict[str, Any]]: + """Build the Matrix thoughts response from the thinking engine. + + Returns recent thoughts formatted for Matrix display: + - id: thought UUID + - text: thought content (truncated to 500 chars) + - created_at: ISO-8601 timestamp + - chain_id: parent thought ID (or null if root thought) + + Returns empty list if thinking engine is disabled or fails. + """ + try: + from timmy.thinking import thinking_engine + + thoughts = thinking_engine.get_recent_thoughts(limit=limit) + return [ + { + "id": t.id, + "text": t.content[:_MAX_THOUGHT_TEXT_LEN], + "created_at": t.created_at, + "chain_id": t.parent_id, + } + for t in thoughts + ] + except Exception as exc: + logger.warning("Failed to load thoughts for Matrix: %s", exc) + return [] + + +@matrix_router.get("/thoughts") +async def get_matrix_thoughts(limit: int = _DEFAULT_THOUGHT_LIMIT) -> JSONResponse: + """Return Timmy's recent thoughts formatted for Matrix display. + + This is the REST companion to the thought WebSocket messages, + allowing the Matrix frontend to display what Timmy is actually + thinking about rather than canned contextual lines. + + Query params: + - limit: Number of thoughts to return (default 10, max 50) + + Response: JSON array of thought objects: + - id: thought UUID + - text: thought content (truncated to 500 chars) + - created_at: ISO-8601 timestamp + - chain_id: parent thought ID (null if root thought) + + Returns empty array if thinking engine is disabled or fails. + """ + # Clamp limit to valid range + if limit < 1: + limit = 1 + elif limit > _MAX_THOUGHT_LIMIT: + limit = _MAX_THOUGHT_LIMIT + + thoughts = _build_matrix_thoughts_response(limit=limit) + return JSONResponse( + content=thoughts, + headers={"Cache-Control": "no-cache, no-store"}, + ) diff --git a/tests/dashboard/test_world_api.py b/tests/dashboard/test_world_api.py index 582ed15b..c4b52263 100644 --- a/tests/dashboard/test_world_api.py +++ b/tests/dashboard/test_world_api.py @@ -1059,6 +1059,164 @@ lighting: assert lights[0]["position"] == {"x": 1, "y": 2, "z": 3} +# --------------------------------------------------------------------------- +# Matrix Thoughts Endpoint (/api/matrix/thoughts) +# --------------------------------------------------------------------------- + + +class TestMatrixThoughtsEndpoint: + """Tests for the Matrix thoughts endpoint.""" + + def test_thoughts_endpoint_returns_json(self, matrix_client): + """GET /api/matrix/thoughts returns JSON list.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [ + MagicMock( + id="test-1", + content="First thought", + created_at="2026-03-21T10:00:00Z", + parent_id=None, + ), + ] + resp = matrix_client.get("/api/matrix/thoughts") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + assert resp.headers["cache-control"] == "no-cache, no-store" + + def test_thoughts_endpoint_default_limit(self, matrix_client): + """Default limit is 10 thoughts.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [] + matrix_client.get("/api/matrix/thoughts") + + # Should call with default limit of 10 + mock_engine.get_recent_thoughts.assert_called_once_with(limit=10) + + def test_thoughts_endpoint_custom_limit(self, matrix_client): + """Custom limit can be specified via query param.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [] + matrix_client.get("/api/matrix/thoughts?limit=25") + + mock_engine.get_recent_thoughts.assert_called_once_with(limit=25) + + def test_thoughts_endpoint_max_limit_capped(self, matrix_client): + """Limit is capped at 50 maximum.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [] + matrix_client.get("/api/matrix/thoughts?limit=100") + + mock_engine.get_recent_thoughts.assert_called_once_with(limit=50) + + def test_thoughts_endpoint_min_limit(self, matrix_client): + """Limit less than 1 is clamped to 1.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [] + matrix_client.get("/api/matrix/thoughts?limit=0") + + mock_engine.get_recent_thoughts.assert_called_once_with(limit=1) + + def test_thoughts_endpoint_response_structure(self, matrix_client): + """Response has all required fields with correct types.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [ + MagicMock( + id="thought-uuid-1", + content="This is a test thought", + created_at="2026-03-21T10:00:00Z", + parent_id="parent-uuid", + ), + ] + resp = matrix_client.get("/api/matrix/thoughts") + + data = resp.json() + assert len(data) == 1 + thought = data[0] + assert thought["id"] == "thought-uuid-1" + assert thought["text"] == "This is a test thought" + assert thought["created_at"] == "2026-03-21T10:00:00Z" + assert thought["chain_id"] == "parent-uuid" + + def test_thoughts_endpoint_null_parent_id(self, matrix_client): + """Root thoughts have chain_id as null.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [ + MagicMock( + id="root-thought", + content="Root thought", + created_at="2026-03-21T10:00:00Z", + parent_id=None, + ), + ] + resp = matrix_client.get("/api/matrix/thoughts") + + data = resp.json() + assert data[0]["chain_id"] is None + + def test_thoughts_endpoint_text_truncation(self, matrix_client): + """Text is truncated to 500 characters.""" + long_content = "A" * 1000 + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [ + MagicMock( + id="long-thought", + content=long_content, + created_at="2026-03-21T10:00:00Z", + parent_id=None, + ), + ] + resp = matrix_client.get("/api/matrix/thoughts") + + data = resp.json() + assert len(data[0]["text"]) == 500 + assert data[0]["text"] == "A" * 500 + + def test_thoughts_endpoint_empty_list(self, matrix_client): + """Endpoint returns 200 with empty list when no thoughts.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [] + resp = matrix_client.get("/api/matrix/thoughts") + + assert resp.status_code == 200 + assert resp.json() == [] + + def test_thoughts_endpoint_graceful_degradation(self, matrix_client): + """Returns empty list when thinking engine fails.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.side_effect = RuntimeError("Engine down") + resp = matrix_client.get("/api/matrix/thoughts") + + assert resp.status_code == 200 + assert resp.json() == [] + + def test_thoughts_endpoint_multiple_thoughts(self, matrix_client): + """Multiple thoughts are returned in order from thinking engine.""" + with patch("timmy.thinking.thinking_engine") as mock_engine: + mock_engine.get_recent_thoughts.return_value = [ + MagicMock( + id="t1", + content="First", + created_at="2026-03-21T10:00:00Z", + parent_id=None, + ), + MagicMock( + id="t2", + content="Second", + created_at="2026-03-21T10:01:00Z", + parent_id="t1", + ), + ] + resp = matrix_client.get("/api/matrix/thoughts") + + data = resp.json() + assert len(data) == 2 + assert data[0]["id"] == "t1" + assert data[1]["id"] == "t2" + + # --------------------------------------------------------------------------- # Matrix Bark Endpoint (/api/matrix/bark) # ---------------------------------------------------------------------------