feat(04-02): add matrix context management commands
- add save/load/reset/context handlers and matrix interception flows - persist current session and last token usage in prototype state
This commit is contained in:
parent
da0b76882e
commit
b52fdc4670
7 changed files with 638 additions and 21 deletions
237
tests/adapter/matrix/test_context_commands.py
Normal file
237
tests/adapter/matrix/test_context_commands.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.bot import MatrixBot, build_runtime
|
||||
from adapter.matrix.handlers.context_commands import (
|
||||
make_handle_context,
|
||||
make_handle_load,
|
||||
make_handle_reset,
|
||||
make_handle_save,
|
||||
)
|
||||
from adapter.matrix.store import get_load_pending, get_reset_pending, set_load_pending, set_reset_pending
|
||||
from core.protocol import IncomingCommand, OutgoingMessage
|
||||
from core.store import InMemoryStore
|
||||
from sdk.interface import MessageResponse
|
||||
from sdk.mock import MockPlatformClient
|
||||
from sdk.prototype_state import PrototypeStateStore
|
||||
|
||||
|
||||
class MatrixCommandPlatform(MockPlatformClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._prototype_state = PrototypeStateStore()
|
||||
self._agent_api = object()
|
||||
self.send_message = AsyncMock(
|
||||
return_value=MessageResponse(
|
||||
message_id="msg-1",
|
||||
response="ok",
|
||||
tokens_used=0,
|
||||
finished=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_command_auto_name_records_session():
|
||||
platform = MatrixCommandPlatform()
|
||||
store = InMemoryStore()
|
||||
handler = make_handle_save(
|
||||
agent_api=platform._agent_api,
|
||||
store=store,
|
||||
prototype_state=platform._prototype_state,
|
||||
)
|
||||
event = IncomingCommand(
|
||||
user_id="u1",
|
||||
platform="matrix",
|
||||
chat_id="!room:example.org",
|
||||
command="save",
|
||||
args=[],
|
||||
)
|
||||
|
||||
result = await handler(event, None, platform, None, None)
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], OutgoingMessage)
|
||||
assert "Сохранение запущено" in result[0].text
|
||||
sessions = await platform._prototype_state.list_saved_sessions("u1")
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["name"].startswith("context-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_command_with_name_uses_given_name():
|
||||
platform = MatrixCommandPlatform()
|
||||
store = InMemoryStore()
|
||||
handler = make_handle_save(
|
||||
agent_api=platform._agent_api,
|
||||
store=store,
|
||||
prototype_state=platform._prototype_state,
|
||||
)
|
||||
event = IncomingCommand(
|
||||
user_id="u1",
|
||||
platform="matrix",
|
||||
chat_id="!room:example.org",
|
||||
command="save",
|
||||
args=["my-session"],
|
||||
)
|
||||
|
||||
await handler(event, None, platform, None, None)
|
||||
|
||||
sessions = await platform._prototype_state.list_saved_sessions("u1")
|
||||
assert [session["name"] for session in sessions] == ["my-session"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_command_shows_numbered_list_and_sets_pending():
|
||||
platform = MatrixCommandPlatform()
|
||||
runtime = build_runtime(platform=platform)
|
||||
await runtime.chat_mgr.get_or_create(
|
||||
user_id="u1",
|
||||
chat_id="C1",
|
||||
platform="matrix",
|
||||
surface_ref="!room:example.org",
|
||||
name="Chat 1",
|
||||
)
|
||||
await platform._prototype_state.add_saved_session("u1", "session-a")
|
||||
await platform._prototype_state.add_saved_session("u1", "session-b")
|
||||
|
||||
handler = make_handle_load(store=runtime.store, prototype_state=platform._prototype_state)
|
||||
event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
|
||||
|
||||
result = await handler(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
|
||||
|
||||
assert "1. session-a" in result[0].text
|
||||
assert "2. session-b" in result[0].text
|
||||
pending = await get_load_pending(runtime.store, "u1", "!room:example.org")
|
||||
assert pending is not None
|
||||
assert [session["name"] for session in pending["saves"]] == ["session-a", "session-b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_command_without_saved_sessions_reports_empty():
|
||||
platform = MatrixCommandPlatform()
|
||||
store = InMemoryStore()
|
||||
handler = make_handle_load(store=store, prototype_state=platform._prototype_state)
|
||||
event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
|
||||
|
||||
result = await handler(event, None, platform, None, None)
|
||||
|
||||
assert "Нет сохранённых сессий" in result[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_command_shows_dialog_and_sets_pending():
|
||||
platform = MatrixCommandPlatform()
|
||||
runtime = build_runtime(platform=platform)
|
||||
await runtime.chat_mgr.get_or_create(
|
||||
user_id="u1",
|
||||
chat_id="C1",
|
||||
platform="matrix",
|
||||
surface_ref="!room:example.org",
|
||||
name="Chat 1",
|
||||
)
|
||||
handler = make_handle_reset(store=runtime.store, agent_base_url="http://127.0.0.1:8000")
|
||||
event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="reset", args=[])
|
||||
|
||||
result = await handler(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
|
||||
|
||||
assert "!yes" in result[0].text
|
||||
assert "!save" in result[0].text
|
||||
assert "!no" in result[0].text
|
||||
assert await get_reset_pending(runtime.store, "u1", "!room:example.org") == {"active": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_endpoint_unavailable_reports_error():
|
||||
with patch("adapter.matrix.handlers.context_commands.httpx.AsyncClient") as client_cls:
|
||||
client = client_cls.return_value
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
|
||||
from adapter.matrix.handlers.context_commands import _call_reset_endpoint
|
||||
|
||||
result = await _call_reset_endpoint("http://127.0.0.1:8000", "!room:example.org")
|
||||
|
||||
assert "недоступен" in result[0].text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_command_shows_current_snapshot():
|
||||
platform = MatrixCommandPlatform()
|
||||
store = InMemoryStore()
|
||||
await platform._prototype_state.set_current_session("u1", "session-a")
|
||||
await platform._prototype_state.set_last_tokens_used("u1", 99)
|
||||
await platform._prototype_state.add_saved_session("u1", "session-a")
|
||||
handler = make_handle_context(store=store, prototype_state=platform._prototype_state)
|
||||
event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="context", args=[])
|
||||
|
||||
result = await handler(event, None, platform, None, None)
|
||||
|
||||
assert "Сессия: session-a" in result[0].text
|
||||
assert "Токены (последний ответ): 99" in result[0].text
|
||||
assert "session-a" in result[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_intercepts_numeric_load_selection():
|
||||
platform = MatrixCommandPlatform()
|
||||
runtime = build_runtime(platform=platform)
|
||||
client = SimpleNamespace(
|
||||
user_id="@bot:example.org",
|
||||
room_send=AsyncMock(),
|
||||
)
|
||||
bot = MatrixBot(client, runtime)
|
||||
await set_load_pending(
|
||||
runtime.store,
|
||||
"@alice:example.org",
|
||||
"!room:example.org",
|
||||
{"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
|
||||
)
|
||||
room = SimpleNamespace(room_id="!room:example.org")
|
||||
event = SimpleNamespace(sender="@alice:example.org", body="1")
|
||||
|
||||
await bot.on_room_message(room, event)
|
||||
|
||||
platform.send_message.assert_awaited_once()
|
||||
assert await platform._prototype_state.get_current_session("@alice:example.org") == "session-a"
|
||||
client.room_send.assert_awaited_once_with(
|
||||
"!room:example.org",
|
||||
"m.room.message",
|
||||
{"msgtype": "m.text", "body": "Загрузка: session-a"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_intercepts_reset_yes_before_dispatch():
|
||||
platform = MatrixCommandPlatform()
|
||||
runtime = build_runtime(platform=platform)
|
||||
client = SimpleNamespace(
|
||||
user_id="@bot:example.org",
|
||||
room_send=AsyncMock(),
|
||||
)
|
||||
bot = MatrixBot(client, runtime)
|
||||
runtime.dispatcher.dispatch = AsyncMock()
|
||||
await set_reset_pending(runtime.store, "@alice:example.org", "!room:example.org", {"active": True})
|
||||
room = SimpleNamespace(room_id="!room:example.org")
|
||||
event = SimpleNamespace(sender="@alice:example.org", body="!yes")
|
||||
|
||||
with patch("adapter.matrix.handlers.context_commands.httpx.AsyncClient") as client_cls:
|
||||
http_client = client_cls.return_value
|
||||
http_client.__aenter__ = AsyncMock(return_value=http_client)
|
||||
http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
http_client.post = AsyncMock(return_value=SimpleNamespace(status_code=200))
|
||||
|
||||
await bot.on_room_message(room, event)
|
||||
|
||||
runtime.dispatcher.dispatch.assert_not_awaited()
|
||||
client.room_send.assert_awaited_once_with(
|
||||
"!room:example.org",
|
||||
"m.room.message",
|
||||
{"msgtype": "m.text", "body": "Контекст сброшен."},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue