[kimi] Add /api/matrix/thoughts endpoint for recent thought stream (#677) #739
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user