feat(mcp): fable_list_tags + fable_get_recent

Two cross-type bootstrap tools:

- fable_list_tags: tag vocabulary with usage counts, top-N by count.
  Aggregation in Python (not SQL UNNEST) — trivial perf cost at
  personal scale, much easier to test.

- fable_get_recent: most-recently-touched items across notes, tasks,
  projects, events. Useful for Claude to ask 'what was I working on
  recently' at the start of a conversation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:51:59 -04:00
parent 9a76c4718b
commit 6961144c3a
4 changed files with 201 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
"""Tests for fable_list_tags.
The aggregation helper is unit-tested directly; the full tool is exercised
with a mocked async_session to keep tests DB-free.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.tags import fable_list_tags, _aggregate_tag_counts
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def test_aggregate_tag_counts_basic():
assert _aggregate_tag_counts([["ops", "kafka"], ["ops"]]) == {"ops": 2, "kafka": 1}
def test_aggregate_tag_counts_handles_none_rows():
"""A note with NULL tags must not blow up the aggregator."""
assert _aggregate_tag_counts([["a"], None, ["a", "b"]]) == {"a": 2, "b": 1}
def test_aggregate_tag_counts_empty_input():
assert _aggregate_tag_counts([]) == {}
@pytest.mark.asyncio
async def test_fable_list_tags_returns_sorted_by_count_desc():
"""End-to-end: query returns three rows, top tag wins."""
mock_result = MagicMock()
mock_result.all.return_value = [(["a"],), (["a", "b"],), (["a"],)]
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session)
with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx):
out = await fable_list_tags()
assert out["tags"][0] == {"tag": "a", "count": 3}
assert out["tags"][1] == {"tag": "b", "count": 1}
assert out["total"] == 2
@pytest.mark.asyncio
async def test_fable_list_tags_clamps_limit():
mock_result = MagicMock()
mock_result.all.return_value = []
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=mock_result)
mock_ctx = MagicMock(return_value=mock_session)
with patch("fabledassistant.mcp.tools.tags.async_session", mock_ctx):
# No exception — just exercises the clamping branch
out = await fable_list_tags(limit=99999)
assert out["total"] == 0