feat(task-5): scope matrix context state per room

This commit is contained in:
Mikhail Putilovskij 2026-04-19 17:41:04 +03:00
parent 03160a3b37
commit c11c8ecfbf
7 changed files with 189 additions and 72 deletions

View file

@ -13,7 +13,12 @@ from adapter.matrix.handlers.context_commands import (
make_handle_reset,
make_handle_save,
)
from adapter.matrix.store import get_load_pending, get_reset_pending, set_load_pending, set_reset_pending
from adapter.matrix.store import (
get_load_pending,
get_reset_pending,
set_load_pending,
set_room_meta,
)
from core.protocol import IncomingCommand, OutgoingMessage
from core.store import InMemoryStore
from sdk.interface import MessageResponse
@ -40,6 +45,11 @@ class MatrixCommandPlatform(MockPlatformClient):
async def test_save_command_auto_name_records_session():
platform = MatrixCommandPlatform()
store = InMemoryStore()
await set_room_meta(
store,
"!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "matrix:room-1"},
)
handler = make_handle_save(
agent_api=platform._agent_api,
store=store,
@ -57,16 +67,22 @@ async def test_save_command_auto_name_records_session():
assert len(result) == 1
assert isinstance(result[0], OutgoingMessage)
assert "Сохранение запущено" in result[0].text
assert "Запрос на сохранение отправлен агенту" in result[0].text
sessions = await platform._prototype_state.list_saved_sessions("u1")
assert len(sessions) == 1
assert sessions[0]["name"].startswith("context-")
assert sessions[0]["source_context_id"] == "matrix:room-1"
@pytest.mark.asyncio
async def test_save_command_with_name_uses_given_name():
platform = MatrixCommandPlatform()
store = InMemoryStore()
await set_room_meta(
store,
"!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "matrix:room-1"},
)
handler = make_handle_save(
agent_api=platform._agent_api,
store=store,
@ -164,15 +180,28 @@ async def test_reset_endpoint_unavailable_reports_error():
@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)
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 set_room_meta(
runtime.store,
"!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "matrix:room-1"},
)
await platform._prototype_state.set_current_session("matrix:room-1", "session-a")
await platform._prototype_state.set_last_tokens_used("matrix:room-1", 99)
await platform._prototype_state.add_saved_session("u1", "session-a")
handler = make_handle_context(store=store, prototype_state=platform._prototype_state)
handler = make_handle_context(store=runtime.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)
result = await handler(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
assert "Контекст чата: matrix:room-1" in result[0].text
assert "Сессия: session-a" in result[0].text
assert "Токены (последний ответ): 99" in result[0].text
assert "session-a" in result[0].text
@ -182,6 +211,15 @@ async def test_context_command_shows_current_snapshot():
async def test_bot_intercepts_numeric_load_selection():
platform = MatrixCommandPlatform()
runtime = build_runtime(platform=platform)
await set_room_meta(
runtime.store,
"!room:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:room-1",
},
)
client = SimpleNamespace(
user_id="@bot:example.org",
room_send=AsyncMock(),
@ -199,39 +237,10 @@ async def test_bot_intercepts_numeric_load_selection():
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"
assert await platform._prototype_state.get_current_session("matrix:room-1") == "session-a"
assert await platform._prototype_state.get_current_session("C1") == "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": "Контекст сброшен."},
{"msgtype": "m.text", "body": "Запрос на загрузку отправлен агенту: session-a"},
)

View file

@ -95,13 +95,18 @@ async def test_update_settings_supports_toggle_skill_and_setters():
async def test_add_saved_session_appends_named_entries():
store = PrototypeStateStore()
await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
await store.add_saved_session(
"usr-matrix-@alice:example.org",
"alpha",
source_context_id="ctx-room-1",
)
await store.add_saved_session("usr-matrix-@alice:example.org", "beta")
sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
assert [session["name"] for session in sessions] == ["alpha", "beta"]
assert all("created_at" in session for session in sessions)
assert sessions[0]["source_context_id"] == "ctx-room-1"
@pytest.mark.asyncio
@ -122,24 +127,58 @@ async def test_list_saved_sessions_returns_copy():
async def test_get_last_tokens_used_defaults_to_zero():
store = PrototypeStateStore()
assert await store.get_last_tokens_used("usr-matrix-@alice:example.org") == 0
assert await store.get_last_tokens_used_for_context("ctx-room-1") == 0
@pytest.mark.asyncio
async def test_set_last_tokens_used_persists_value():
async def test_live_tokens_used_are_scoped_per_context():
store = PrototypeStateStore()
await store.set_last_tokens_used("usr-matrix-@alice:example.org", 321)
await store.set_last_tokens_used_for_context("ctx-room-1", 321)
await store.set_last_tokens_used_for_context("ctx-room-2", 654)
assert await store.get_last_tokens_used("usr-matrix-@alice:example.org") == 321
assert await store.get_last_tokens_used_for_context("ctx-room-1") == 321
assert await store.get_last_tokens_used_for_context("ctx-room-2") == 654
@pytest.mark.asyncio
async def test_current_session_roundtrip():
async def test_current_session_roundtrip_is_scoped_per_context():
store = PrototypeStateStore()
assert await store.get_current_session("usr-matrix-@alice:example.org") is None
assert await store.get_current_session_for_context("ctx-room-1") is None
assert await store.get_current_session_for_context("ctx-room-2") is None
await store.set_current_session("usr-matrix-@alice:example.org", "session-1")
await store.set_current_session_for_context("ctx-room-1", "session-1")
await store.set_current_session_for_context("ctx-room-2", "session-2")
assert await store.get_current_session("usr-matrix-@alice:example.org") == "session-1"
assert await store.get_current_session_for_context("ctx-room-1") == "session-1"
assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
@pytest.mark.asyncio
async def test_clear_current_session_removes_only_target_context():
store = PrototypeStateStore()
await store.set_current_session_for_context("ctx-room-1", "session-1")
await store.set_current_session_for_context("ctx-room-2", "session-2")
await store.clear_current_session_for_context("ctx-room-1")
assert await store.get_current_session_for_context("ctx-room-1") is None
assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
@pytest.mark.asyncio
async def test_saved_sessions_remain_user_scoped_separate_from_live_context_state():
store = PrototypeStateStore()
await store.set_current_session_for_context("ctx-room-1", "room-session")
await store.set_last_tokens_used_for_context("ctx-room-1", 77)
await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
assert [session["name"] for session in sessions] == ["alpha"]
assert all(isinstance(session["created_at"], str) for session in sessions)
assert await store.get_current_session_for_context("ctx-room-1") == "room-session"
assert await store.get_last_tokens_used_for_context("ctx-room-1") == 77

View file

@ -197,9 +197,10 @@ async def test_real_platform_client_get_or_create_user_uses_local_state():
@pytest.mark.asyncio
async def test_real_platform_client_send_message_uses_chat_bound_client():
agent_api = FakeAgentApiFactory()
prototype_state = PrototypeStateStore()
client = RealPlatformClient(
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
prototype_state=prototype_state,
platform="matrix",
)
@ -215,6 +216,7 @@ async def test_real_platform_client_send_message_uses_chat_bound_client():
assert agent_api.instances["chat-7"].chat_id == "chat-7"
assert agent_api.instances["chat-7"].calls == ["hello"]
assert agent_api.instances["chat-7"].connect_calls == 1
assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 3
@pytest.mark.asyncio