feat(matrix): implement !reset via new platform_chat_id

Instead of calling a /reset endpoint on platform-agent, !reset now
generates a new thread_id (platform_chat_id) for the room. The old
WebSocket connection is closed and the next message creates a fresh
context automatically. No platform changes required.
This commit is contained in:
Mikhail Putilovskij 2026-04-19 21:20:31 +03:00
parent 4a5260ca79
commit 73c472ecc4
4 changed files with 45 additions and 46 deletions

View file

@ -10,6 +10,7 @@ from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_conf
from adapter.matrix.handlers.context_commands import ( from adapter.matrix.handlers.context_commands import (
make_handle_context, make_handle_context,
make_handle_load, make_handle_load,
make_handle_reset,
make_handle_save, make_handle_save,
) )
from adapter.matrix.handlers.settings import ( from adapter.matrix.handlers.settings import (
@ -43,7 +44,7 @@ def register_matrix_handlers(
dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store))
dispatcher.register(IncomingCommand, "help", handle_help) dispatcher.register(IncomingCommand, "help", handle_help)
dispatcher.register(IncomingCommand, "settings", handle_settings) dispatcher.register(IncomingCommand, "settings", handle_settings)
dispatcher.register(IncomingCommand, "reset", handle_settings) dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, prototype_state) if prototype_state is not None else handle_settings)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills) dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors) dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)
dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul) dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul)

View file

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
import httpx import httpx
import structlog import structlog
from adapter.matrix.store import get_room_meta, set_load_pending, set_reset_pending from adapter.matrix.store import get_room_meta, set_load_pending, set_platform_chat_id
from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage
if TYPE_CHECKING: if TYPE_CHECKING:
@ -123,23 +123,26 @@ def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"
return handle_load return handle_load
def make_handle_reset(store: "StateStore", agent_base_url: str): def make_handle_reset(store: "StateStore", prototype_state: "PrototypeStateStore"):
async def handle_reset( async def handle_reset(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]: ) -> list[OutgoingEvent]:
import time
room_id = await _resolve_room_id(event, chat_mgr) room_id = await _resolve_room_id(event, chat_mgr)
await set_reset_pending(store, event.user_id, room_id, {"active": True}) room_meta = await get_room_meta(store, room_id)
return [ old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id
OutgoingMessage(
chat_id=event.chat_id, new_chat_id = f"matrix:{room_id}#{int(time.time())}"
text=( await set_platform_chat_id(store, room_id, new_chat_id)
"Сбросить контекст агента? Выбери:\n"
" !yes - сбросить\n" disconnect = getattr(platform, "disconnect_chat", None)
" !save [имя] - сохранить и сбросить\n" if callable(disconnect):
" !no - отмена" await disconnect(old_chat_id)
),
) await prototype_state.clear_current_session(new_chat_id)
]
return [OutgoingMessage(chat_id=event.chat_id, text="Контекст сброшен. Агент не помнит предыдущий разговор.")]
return handle_reset return handle_reset

View file

@ -120,6 +120,15 @@ class RealPlatformClient(PlatformClient):
async def update_settings(self, user_id: str, action) -> None: async def update_settings(self, user_id: str, action) -> None:
await self._prototype_state.update_settings(user_id, action) await self._prototype_state.update_settings(user_id, action)
async def disconnect_chat(self, chat_id: str) -> None:
chat_key = str(chat_id)
chat_api = self._chat_apis.pop(chat_key, None)
self._chat_send_locks.pop(chat_key, None)
if chat_api is not None:
close = getattr(chat_api, "close", None)
if callable(close):
await close()
async def close(self) -> None: async def close(self) -> None:
for chat_api in list(self._chat_apis.values()): for chat_api in list(self._chat_apis.values()):
close = getattr(chat_api, "close", None) close = getattr(chat_api, "close", None)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx
import pytest import pytest
from adapter.matrix.bot import MatrixBot, build_runtime from adapter.matrix.bot import MatrixBot, build_runtime
@ -15,7 +15,7 @@ from adapter.matrix.handlers.context_commands import (
) )
from adapter.matrix.store import ( from adapter.matrix.store import (
get_load_pending, get_load_pending,
get_reset_pending,
set_load_pending, set_load_pending,
set_room_meta, set_room_meta,
) )
@ -141,40 +141,26 @@ async def test_load_command_without_saved_sessions_reports_empty():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reset_command_shows_dialog_and_sets_pending(): async def test_reset_command_assigns_new_platform_chat_id():
from adapter.matrix.store import get_platform_chat_id, set_room_meta
from sdk.prototype_state import PrototypeStateStore
prototype_state = PrototypeStateStore()
platform = MatrixCommandPlatform() platform = MatrixCommandPlatform()
runtime = build_runtime(platform=platform) runtime = build_runtime(platform=platform)
await runtime.chat_mgr.get_or_create( store = runtime.store
user_id="u1",
chat_id="C1", await set_room_meta(store, "!room:example.org", {"platform_chat_id": "matrix:!room:example.org"})
platform="matrix",
surface_ref="!room:example.org", handler = make_handle_reset(store=store, prototype_state=prototype_state)
name="Chat 1", event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!room:example.org", command="reset", args=[])
)
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) result = await handler(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
assert "!yes" in result[0].text new_id = await get_platform_chat_id(store, "!room:example.org")
assert "!save" in result[0].text assert new_id != "matrix:!room:example.org"
assert "!no" in result[0].text assert new_id.startswith("matrix:!room:example.org#")
assert await get_reset_pending(runtime.store, "u1", "!room:example.org") == {"active": True} assert "сброшен" in result[0].text.lower()
@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 @pytest.mark.asyncio