[kimi] Add /api/matrix/thoughts endpoint for recent thought stream (#677) #739

Merged
kimi merged 1 commits from kimi/issue-677 into main 2026-03-21 14:44:47 +00:00
2 changed files with 228 additions and 0 deletions

View File

@@ -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"},
)

View File

@@ -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)
# ---------------------------------------------------------------------------