feat: add Matrix appservice bot and platform-agnostic conversation core
Introduce a shared ConversationService (steward/bot/core.py) that owns the LLM call, history, knowledge-base search, and thread-memory keying behind a normalized ThreadKey, so both Telegram and Matrix drive the same pipeline. - Add steward/bot/matrix.py: a mautrix-python appservice bot that receives Synapse transactions and replies via the client-server API. - Refactor telegram.py handlers into thin wrappers over ConversationService. - Generalize ThreadMemoryStore/ThreadSummary to platform-scoped keys with legacy chat_id:thread_id migration. - Add a matrix config section (homeserver, tokens, room/user allowlists). - Rewrite main.py as async, starting Telegram and/or Matrix on one event loop. - Add mautrix>=0.21.0 dependency. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+34
-24
@@ -6,15 +6,15 @@ import pytest
|
||||
from telegram import Chat, Message, Update, User
|
||||
from telegram.ext import CallbackContext
|
||||
|
||||
from steward.bot.core import ConversationService
|
||||
from steward.bot.telegram import (
|
||||
_history,
|
||||
_is_allowed,
|
||||
_is_chat_enabled,
|
||||
_thread_history,
|
||||
clear_handler,
|
||||
message_handler,
|
||||
start_handler,
|
||||
)
|
||||
from steward.bot.thread_key import ThreadKey
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
from steward.memory.thread_store import ThreadMemoryStore
|
||||
@@ -68,10 +68,13 @@ def _make_context(
|
||||
mock_store = MagicMock(spec=ThreadMemoryStore)
|
||||
mock_store.search.return_value = []
|
||||
store = mock_store
|
||||
if llm is None:
|
||||
llm = MagicMock(spec=LLMClient)
|
||||
service = ConversationService(settings, llm, store)
|
||||
ctx.bot_data = {
|
||||
"settings": settings,
|
||||
"service": service,
|
||||
"llm": llm,
|
||||
"thread_store": store,
|
||||
}
|
||||
ctx.args = []
|
||||
return ctx
|
||||
@@ -140,14 +143,15 @@ async def test_clear_handler_clears_user_history():
|
||||
"""clear_handler without a thread clears per-user history."""
|
||||
settings = _make_settings()
|
||||
user_id = 42
|
||||
_history[user_id] = [{"role": "user", "content": "old msg"}]
|
||||
ctx = _make_context(settings)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope=str(user_id))
|
||||
service._histories[key] = [{"role": "user", "content": "old msg"}]
|
||||
|
||||
update = _make_update(user_id=user_id) # no thread_id
|
||||
ctx = _make_context(settings)
|
||||
|
||||
await clear_handler(update, ctx)
|
||||
|
||||
assert _history[user_id] == []
|
||||
assert not service.has_history(key)
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -155,15 +159,15 @@ async def test_clear_handler_clears_user_history():
|
||||
async def test_clear_handler_clears_thread_history():
|
||||
"""clear_handler inside a thread clears that thread's history."""
|
||||
settings = _make_settings()
|
||||
key = (100, 7)
|
||||
_thread_history[key] = [{"role": "user", "content": "thread msg"}]
|
||||
ctx = _make_context(settings)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope="100", thread="7")
|
||||
service._histories[key] = [{"role": "user", "content": "thread msg"}]
|
||||
|
||||
update = _make_update(user_id=1, chat_id=100, thread_id=7)
|
||||
ctx = _make_context(settings)
|
||||
|
||||
await clear_handler(update, ctx)
|
||||
|
||||
assert _thread_history[key] == []
|
||||
assert not service.has_history(key)
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
|
||||
|
||||
@@ -175,15 +179,17 @@ async def test_message_handler_calls_llm_and_replies():
|
||||
|
||||
update = _make_update(user_id=77, text="What is the weather?")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope="77")
|
||||
|
||||
_history[77].clear()
|
||||
await message_handler(update, ctx)
|
||||
|
||||
mock_llm.chat.assert_awaited_once()
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
assert len(_history[77]) == 2
|
||||
assert _history[77][0]["role"] == "user"
|
||||
assert _history[77][1]["role"] == "assistant"
|
||||
history = service._histories[key]
|
||||
assert len(history) == 2
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[1]["role"] == "assistant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -196,15 +202,16 @@ async def test_message_handler_sends_multiple_reply_messages():
|
||||
|
||||
update = _make_update(user_id=78, text="Can you help with this vague thing?")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope="78")
|
||||
|
||||
_history[78].clear()
|
||||
await message_handler(update, ctx)
|
||||
|
||||
assert update.message.reply_text.await_count == 2
|
||||
assert update.message.reply_text.await_args_list[0].args[0] == "First thought."
|
||||
assert update.message.reply_text.await_args_list[1].args[0] == "What outcome do you want?"
|
||||
update.message.reply_poll.assert_not_awaited()
|
||||
assert _history[78][1]["content"] == "First thought.\n\nWhat outcome do you want?"
|
||||
assert service._histories[key][1]["content"] == "First thought.\n\nWhat outcome do you want?"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -225,8 +232,9 @@ async def test_message_handler_sends_native_poll_from_directive():
|
||||
|
||||
update = _make_update(user_id=79, text="Should we do a minimal change or redesign?")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope="79")
|
||||
|
||||
_history[79].clear()
|
||||
await message_handler(update, ctx)
|
||||
|
||||
assert update.message.reply_text.await_count == 2
|
||||
@@ -235,7 +243,7 @@ async def test_message_handler_sends_native_poll_from_directive():
|
||||
options=["Minimal change", "Full redesign"],
|
||||
is_anonymous=False,
|
||||
)
|
||||
assert _history[79][1]["content"] == (
|
||||
assert service._histories[key][1]["content"] == (
|
||||
"I can turn that into a vote.\n\n"
|
||||
"Poll: Which implementation should we choose? (Minimal change; Full redesign)\n\n"
|
||||
"I'll use the winning option."
|
||||
@@ -245,22 +253,24 @@ async def test_message_handler_sends_native_poll_from_directive():
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_handler_non_thread_trims_history():
|
||||
"""Non-thread history is trimmed to _MAX_HISTORY turns."""
|
||||
from steward.bot.telegram import _MAX_HISTORY
|
||||
from steward.bot.core import _MAX_HISTORY
|
||||
|
||||
settings = _make_settings()
|
||||
mock_llm = MagicMock(spec=LLMClient)
|
||||
mock_llm.chat = AsyncMock(return_value="reply")
|
||||
|
||||
user_id = 200
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope=str(user_id))
|
||||
# Pre-fill exactly at the limit
|
||||
_history[user_id] = [
|
||||
service._histories[key] = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"}
|
||||
for i in range(_MAX_HISTORY * 2)
|
||||
]
|
||||
|
||||
update = _make_update(user_id=user_id, text="new question")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
await message_handler(update, ctx)
|
||||
|
||||
# After appending 2 new messages, should trim back to _MAX_HISTORY * 2
|
||||
assert len(_history[user_id]) == _MAX_HISTORY * 2
|
||||
assert len(service._histories[key]) == _MAX_HISTORY * 2
|
||||
|
||||
+98
-102
@@ -7,12 +7,13 @@ import pytest
|
||||
from telegram import Chat, Message, Update, User
|
||||
from telegram.ext import CallbackContext
|
||||
|
||||
from steward.bot.core import ConversationService
|
||||
from steward.bot.telegram import (
|
||||
_thread_history,
|
||||
flush_handler,
|
||||
message_handler,
|
||||
recall_handler,
|
||||
)
|
||||
from steward.bot.thread_key import ThreadKey
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
|
||||
@@ -64,10 +65,13 @@ def _make_context(
|
||||
mock_store = MagicMock(spec=ThreadMemoryStore)
|
||||
mock_store.search.return_value = []
|
||||
store = mock_store
|
||||
if llm is None:
|
||||
llm = MagicMock(spec=LLMClient)
|
||||
service = ConversationService(settings, llm, store)
|
||||
ctx.bot_data = {
|
||||
"settings": settings,
|
||||
"llm": llm or MagicMock(spec=LLMClient),
|
||||
"thread_store": store,
|
||||
"service": service,
|
||||
"llm": llm,
|
||||
}
|
||||
ctx.args = []
|
||||
return ctx
|
||||
@@ -82,50 +86,56 @@ class TestThreadMemoryStore:
|
||||
def _store(self, tmp_path: Path) -> ThreadMemoryStore:
|
||||
return ThreadMemoryStore(tmp_path / "mem.json")
|
||||
|
||||
def _key(self, scope: str = "1", thread: str | None = "2") -> ThreadKey:
|
||||
return ThreadKey(platform="telegram", scope=scope, thread=thread)
|
||||
|
||||
def _summary(self, scope: str = "1", thread: str | None = "2", **kwargs) -> ThreadSummary:
|
||||
defaults = dict(summary="A recap.", message_count=5)
|
||||
defaults.update(kwargs)
|
||||
return ThreadSummary(platform="telegram", scope=scope, thread=thread, **defaults)
|
||||
|
||||
def test_get_missing_returns_none(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
assert store.get(1, 2) is None
|
||||
assert store.get(self._key()) is None
|
||||
|
||||
def test_save_and_get_roundtrip(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
summary = ThreadSummary(chat_id=1, thread_id=42, summary="A recap.", message_count=5)
|
||||
summary = self._summary()
|
||||
store.save(summary)
|
||||
|
||||
retrieved = store.get(1, 42)
|
||||
retrieved = store.get(self._key())
|
||||
assert retrieved is not None
|
||||
assert retrieved.summary == "A recap."
|
||||
assert retrieved.message_count == 5
|
||||
|
||||
def test_save_overwrites_previous_entry(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="old", message_count=2))
|
||||
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="new", message_count=4))
|
||||
assert store.get(1, 1).summary == "new" # type: ignore[union-attr]
|
||||
store.save(self._summary(summary="old", message_count=2))
|
||||
store.save(self._summary(summary="new", message_count=4))
|
||||
assert store.get(self._key()).summary == "new" # type: ignore[union-attr]
|
||||
|
||||
def test_persisted_to_disk(self, tmp_path: Path):
|
||||
path = tmp_path / "mem.json"
|
||||
store = ThreadMemoryStore(path)
|
||||
store.save(ThreadSummary(chat_id=5, thread_id=9, summary="saved", message_count=1))
|
||||
store.save(self._summary(scope="5", thread="9", summary="saved", message_count=1))
|
||||
|
||||
# Load a fresh store from the same file
|
||||
store2 = ThreadMemoryStore(path)
|
||||
assert store2.get(5, 9) is not None
|
||||
assert store2.get(self._key("5", "9")) is not None
|
||||
|
||||
def test_all_returns_newest_first(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=1,
|
||||
self._summary(
|
||||
thread="1",
|
||||
summary="first",
|
||||
message_count=1,
|
||||
flushed_at="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=2,
|
||||
self._summary(
|
||||
thread="2",
|
||||
summary="second",
|
||||
message_count=1,
|
||||
flushed_at="2026-06-01T00:00:00+00:00",
|
||||
@@ -142,19 +152,13 @@ class TestThreadMemoryStore:
|
||||
assert store.all() == []
|
||||
|
||||
def test_format_for_telegram_contains_thread_id(self, tmp_path: Path):
|
||||
s = ThreadSummary(chat_id=1, thread_id=77, summary="recap", message_count=3)
|
||||
s = self._summary(thread="77", summary="recap", message_count=3)
|
||||
text = s.format_for_telegram()
|
||||
assert "77" in text
|
||||
assert "recap" in text
|
||||
|
||||
def test_format_for_telegram_shows_tags(self, tmp_path: Path):
|
||||
s = ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=77,
|
||||
summary="recap",
|
||||
message_count=3,
|
||||
tags=["api", "auth"],
|
||||
)
|
||||
s = self._summary(thread="77", summary="recap", message_count=3, tags=["api", "auth"])
|
||||
text = s.format_for_telegram()
|
||||
assert "api" in text
|
||||
assert "auth" in text
|
||||
@@ -174,55 +178,37 @@ class TestThreadMemoryStore:
|
||||
def test_search_returns_matching_summaries(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=1,
|
||||
summary="API work",
|
||||
message_count=2,
|
||||
tags=["api", "design", "auth"],
|
||||
self._summary(
|
||||
thread="1", summary="API work", message_count=2, tags=["api", "design", "auth"]
|
||||
)
|
||||
)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=2,
|
||||
summary="Database work",
|
||||
message_count=2,
|
||||
tags=["database", "schema"],
|
||||
self._summary(
|
||||
thread="2", summary="Database work", message_count=2, tags=["database", "schema"]
|
||||
)
|
||||
)
|
||||
results = store.search("api")
|
||||
assert len(results) == 1
|
||||
assert results[0].thread_id == 1
|
||||
assert results[0].thread_id == "1"
|
||||
|
||||
def test_search_no_match_returns_empty(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"]
|
||||
)
|
||||
)
|
||||
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=["database"]))
|
||||
assert store.search("deployment") == []
|
||||
|
||||
def test_search_empty_query_returns_empty(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"]
|
||||
)
|
||||
)
|
||||
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=["database"]))
|
||||
assert store.search("") == []
|
||||
|
||||
def test_search_case_insensitive(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(
|
||||
ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["API"])
|
||||
)
|
||||
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=["API"]))
|
||||
assert len(store.search("api")) == 1
|
||||
|
||||
def test_search_skips_untagged_summaries(self, tmp_path: Path):
|
||||
store = self._store(tmp_path)
|
||||
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1, tags=[]))
|
||||
store.save(self._summary(thread="1", summary="recap", message_count=1, tags=[]))
|
||||
assert store.search("api") == []
|
||||
|
||||
def test_legacy_store_roundtrip(self, tmp_path: Path):
|
||||
@@ -245,7 +231,7 @@ class TestThreadMemoryStore:
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = ThreadMemoryStore(path)
|
||||
s = store.get(1, 1)
|
||||
s = store.get(self._key("1", "1"))
|
||||
assert s is not None
|
||||
assert s.tags == []
|
||||
|
||||
@@ -257,49 +243,49 @@ class TestThreadMemoryStore:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_message_stored_in_thread_history():
|
||||
"""Messages in a thread go to _thread_history, not _history."""
|
||||
from steward.bot.telegram import _history
|
||||
|
||||
"""Messages in a thread go to the thread's history, not the user's."""
|
||||
settings = _make_settings()
|
||||
mock_llm = MagicMock(spec=LLMClient)
|
||||
mock_llm.chat = AsyncMock(return_value="thread reply")
|
||||
|
||||
key = (100, 55)
|
||||
_thread_history[key].clear()
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
thread_key = ThreadKey(platform="telegram", scope="100", thread="55")
|
||||
user_key = ThreadKey(platform="telegram", scope="1")
|
||||
|
||||
update = _make_update(user_id=1, chat_id=100, thread_id=55, text="thread message")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
await message_handler(update, ctx)
|
||||
|
||||
assert len(_thread_history[key]) == 2
|
||||
assert _thread_history[key][0]["role"] == "user"
|
||||
assert len(service._histories[thread_key]) == 2
|
||||
assert service._histories[thread_key][0]["role"] == "user"
|
||||
# Regular user history untouched
|
||||
assert len(_history[1]) == 0
|
||||
assert not service.has_history(user_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_history_is_unbounded():
|
||||
"""Thread history never gets trimmed regardless of how many turns there are."""
|
||||
from steward.bot.telegram import _MAX_HISTORY
|
||||
from steward.bot.core import _MAX_HISTORY
|
||||
|
||||
settings = _make_settings()
|
||||
mock_llm = MagicMock(spec=LLMClient)
|
||||
mock_llm.chat = AsyncMock(return_value="reply")
|
||||
|
||||
key = (200, 66)
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
thread_key = ThreadKey(platform="telegram", scope="200", thread="66")
|
||||
# Pre-fill well beyond the cap used for non-thread history
|
||||
_thread_history[key] = [
|
||||
service._histories[thread_key] = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
|
||||
for i in range(_MAX_HISTORY * 4) # 4× the normal cap
|
||||
]
|
||||
prior_len = len(_thread_history[key])
|
||||
prior_len = len(service._histories[thread_key])
|
||||
|
||||
update = _make_update(user_id=2, chat_id=200, thread_id=66, text="more")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
await message_handler(update, ctx)
|
||||
|
||||
# Should have grown by exactly 2 (user + assistant), never trimmed
|
||||
assert len(_thread_history[key]) == prior_len + 2
|
||||
assert len(service._histories[thread_key]) == prior_len + 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -333,9 +319,6 @@ async def test_flush_empty_thread_warns():
|
||||
mock_llm = MagicMock(spec=LLMClient)
|
||||
mock_llm.chat = AsyncMock(return_value="summary")
|
||||
|
||||
key = (300, 88)
|
||||
_thread_history[key].clear()
|
||||
|
||||
update = _make_update(chat_id=300, thread_id=88)
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
await flush_handler(update, ctx)
|
||||
@@ -353,8 +336,10 @@ async def test_flush_summarises_stores_and_compresses():
|
||||
# First call → summary, second call → tags
|
||||
mock_llm.chat = AsyncMock(side_effect=["Great summary of the thread.", "api, design, testing"])
|
||||
|
||||
key = (400, 99)
|
||||
_thread_history[key] = [
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
key = ThreadKey(platform="telegram", scope="400", thread="99")
|
||||
service._histories[key] = [
|
||||
{"role": "user", "content": "question one"},
|
||||
{"role": "assistant", "content": "answer one"},
|
||||
{"role": "user", "content": "question two"},
|
||||
@@ -362,7 +347,6 @@ async def test_flush_summarises_stores_and_compresses():
|
||||
]
|
||||
|
||||
update = _make_update(chat_id=400, thread_id=99)
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
await flush_handler(update, ctx)
|
||||
|
||||
# LLM called twice: once for summary, once for tags
|
||||
@@ -370,16 +354,17 @@ async def test_flush_summarises_stores_and_compresses():
|
||||
# Summary persisted
|
||||
store.save.assert_called_once()
|
||||
saved: ThreadSummary = store.save.call_args.args[0]
|
||||
assert saved.chat_id == 400
|
||||
assert saved.thread_id == 99
|
||||
assert saved.platform == "telegram"
|
||||
assert saved.scope == "400"
|
||||
assert saved.thread == "99"
|
||||
assert saved.summary == "Great summary of the thread."
|
||||
assert saved.message_count == 2 # 2 user turns
|
||||
assert saved.tags == ["api", "design", "testing"]
|
||||
|
||||
# In-memory history replaced with compressed context
|
||||
assert len(_thread_history[key]) == 1
|
||||
assert _thread_history[key][0]["role"] == "system"
|
||||
assert "Great summary" in _thread_history[key][0]["content"]
|
||||
assert len(service._histories[key]) == 1
|
||||
assert service._histories[key][0]["role"] == "system"
|
||||
assert "Great summary" in service._histories[key][0]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -392,7 +377,11 @@ async def test_recall_in_thread_returns_stored_summary(tmp_path):
|
||||
"""recall_handler inside a thread returns the stored summary for that thread."""
|
||||
settings = _make_settings()
|
||||
store = ThreadMemoryStore(tmp_path / "mem.json")
|
||||
store.save(ThreadSummary(chat_id=500, thread_id=11, summary="recap text", message_count=3))
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
platform="telegram", scope="500", thread="11", summary="recap text", message_count=3
|
||||
)
|
||||
)
|
||||
|
||||
update = _make_update(chat_id=500, thread_id=11)
|
||||
ctx = _make_context(settings, store=store)
|
||||
@@ -423,8 +412,12 @@ async def test_recall_outside_thread_lists_all(tmp_path):
|
||||
"""recall_handler outside a thread lists all stored summaries."""
|
||||
settings = _make_settings()
|
||||
store = ThreadMemoryStore(tmp_path / "mem.json")
|
||||
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="alpha", message_count=1))
|
||||
store.save(ThreadSummary(chat_id=1, thread_id=2, summary="beta", message_count=2))
|
||||
store.save(
|
||||
ThreadSummary(platform="telegram", scope="1", thread="1", summary="alpha", message_count=1)
|
||||
)
|
||||
store.save(
|
||||
ThreadSummary(platform="telegram", scope="1", thread="2", summary="beta", message_count=2)
|
||||
)
|
||||
|
||||
update = _make_update(thread_id=None) # no thread
|
||||
ctx = _make_context(settings, store=store)
|
||||
@@ -457,8 +450,9 @@ async def test_recall_with_query_returns_matching_summaries(tmp_path):
|
||||
store = ThreadMemoryStore(tmp_path / "mem.json")
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=1,
|
||||
platform="telegram",
|
||||
scope="1",
|
||||
thread="1",
|
||||
summary="API authentication discussion",
|
||||
message_count=2,
|
||||
tags=["api", "auth"],
|
||||
@@ -466,8 +460,9 @@ async def test_recall_with_query_returns_matching_summaries(tmp_path):
|
||||
)
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=2,
|
||||
platform="telegram",
|
||||
scope="1",
|
||||
thread="2",
|
||||
summary="Database schema planning",
|
||||
message_count=3,
|
||||
tags=["database", "schema"],
|
||||
@@ -492,8 +487,9 @@ async def test_recall_with_query_no_match(tmp_path):
|
||||
store = ThreadMemoryStore(tmp_path / "mem.json")
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=1,
|
||||
platform="telegram",
|
||||
scope="1",
|
||||
thread="1",
|
||||
summary="recap",
|
||||
message_count=1,
|
||||
tags=["database"],
|
||||
@@ -520,8 +516,9 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
|
||||
store = ThreadMemoryStore(tmp_path / "mem.json")
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=1,
|
||||
platform="telegram",
|
||||
scope="1",
|
||||
thread="1",
|
||||
summary="Previous API discussion",
|
||||
message_count=2,
|
||||
tags=["api"],
|
||||
@@ -529,12 +526,11 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
|
||||
)
|
||||
|
||||
user_id = 999
|
||||
from steward.bot.telegram import _history
|
||||
|
||||
_history[user_id].clear()
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
service: ConversationService = ctx.bot_data["service"]
|
||||
user_key = ThreadKey(platform="telegram", scope=str(user_id))
|
||||
|
||||
update = _make_update(user_id=user_id, text="tell me about the api work")
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
await message_handler(update, ctx)
|
||||
|
||||
# LLM was called
|
||||
@@ -547,8 +543,10 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
|
||||
assert history_arg is not None
|
||||
assert any("knowledge base" in m.get("content", "").lower() for m in history_arg)
|
||||
|
||||
# But the KB message must NOT be stored in _history
|
||||
assert all("knowledge base" not in m.get("content", "").lower() for m in _history[user_id])
|
||||
# But the KB message must NOT be stored in history
|
||||
assert all(
|
||||
"knowledge base" not in m.get("content", "").lower() for m in service._histories[user_key]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -562,8 +560,9 @@ async def test_message_handler_no_kb_injection_when_no_match(tmp_path):
|
||||
# Store a summary with unrelated tags
|
||||
store.save(
|
||||
ThreadSummary(
|
||||
chat_id=1,
|
||||
thread_id=1,
|
||||
platform="telegram",
|
||||
scope="1",
|
||||
thread="1",
|
||||
summary="Database recap",
|
||||
message_count=1,
|
||||
tags=["database"],
|
||||
@@ -571,12 +570,9 @@ async def test_message_handler_no_kb_injection_when_no_match(tmp_path):
|
||||
)
|
||||
|
||||
user_id = 888
|
||||
from steward.bot.telegram import _history
|
||||
|
||||
_history[user_id].clear()
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
|
||||
update = _make_update(user_id=user_id, text="what is the weather like?")
|
||||
ctx = _make_context(settings, llm=mock_llm, store=store)
|
||||
await message_handler(update, ctx)
|
||||
|
||||
call_kwargs = mock_llm.chat.call_args
|
||||
|
||||
Reference in New Issue
Block a user