surfaces/tests/adapter/matrix/test_dispatcher.py

1108 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from nio import (
RoomMessageAudio,
RoomMessageFile,
RoomMessageImage,
RoomMessageText,
RoomMessageVideo,
)
from nio.api import RoomVisibility
from nio.responses import SyncResponse
from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.routed_platform import RoutedPlatformClient
from adapter.matrix.store import (
add_staged_attachment,
get_platform_chat_id,
get_room_meta,
get_staged_attachments,
get_user_meta,
set_load_pending,
set_room_meta,
set_user_meta,
)
from core.protocol import (
Attachment,
IncomingCallback,
IncomingCommand,
IncomingMessage,
OutgoingMessage,
)
from sdk.interface import PlatformError
from sdk.mock import MockPlatformClient
async def test_matrix_dispatcher_registers_custom_handlers():
runtime = build_runtime(platform=MockPlatformClient())
current_chat_id = "C9"
start = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
)
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Research"]
)
result = await runtime.dispatcher.dispatch(new)
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C1"]
assert [c.surface_ref for c in chats] == [current_chat_id]
new2 = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Ops"]
)
await runtime.dispatcher.dispatch(new2)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C1", "C2"]
skills = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings_skills"
)
result = await runtime.dispatcher.dispatch(skills)
assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
toggle = IncomingCallback(
user_id="u1",
platform="matrix",
chat_id="C1",
action="toggle_skill",
payload={"skill_index": 2},
)
result = await runtime.dispatcher.dispatch(toggle)
assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
async def test_new_chat_creates_real_matrix_room_when_client_available():
client = SimpleNamespace(
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
runtime = build_runtime(platform=MockPlatformClient(), client=client)
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="start")
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1",
platform="matrix",
chat_id="C3",
command="new",
args=["Research"],
)
result = await runtime.dispatcher.dispatch(new)
# room_create is now called with agent_id=None when registry is not configured
assert client.room_create.await_count >= 1
client.room_put_state.assert_awaited_once()
put_call = client.room_put_state.call_args
assert (
put_call.kwargs.get("room_id") == "!space:example"
or put_call.args[0] == "!space:example"
)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C7"]
assert [c.surface_ref for c in chats] == ["!r2:example"]
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
async def test_invite_event_creates_space_and_chat_room():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(
client,
room,
event,
runtime.platform,
runtime.store,
runtime.auth_mgr,
runtime.chat_mgr,
)
assert client.room_create.await_count == 2
first_call = client.room_create.call_args_list[0]
assert first_call.kwargs.get("space") is True or (
len(first_call.args) > 0 and first_call.kwargs.get("space") is True
)
assert first_call.kwargs.get("visibility") is RoomVisibility.private
assert first_call.kwargs.get("invite") == ["@alice:example.org"]
second_call = client.room_create.call_args_list[1]
assert second_call.kwargs.get("visibility") is RoomVisibility.private
assert second_call.kwargs.get("invite") == ["@alice:example.org"]
client.room_invite.assert_not_awaited()
client.room_put_state.assert_awaited_once()
put_state_call = client.room_put_state.call_args
assert (
put_state_call.kwargs.get("event_type") == "m.space.child"
or put_state_call.args[1] == "m.space.child"
)
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None
assert user_meta.get("space_id") == "!space:example.org"
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
assert room_meta is not None
assert room_meta["chat_id"] == "C4"
assert room_meta["space_id"] == "!space:example.org"
assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
assert user_meta.get("next_chat_index") == 5
client.room_send.assert_awaited_once()
async def test_invite_event_is_idempotent_per_user():
runtime = build_runtime(platform=MockPlatformClient())
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(
client,
room,
event,
runtime.platform,
runtime.store,
runtime.auth_mgr,
runtime.chat_mgr,
)
await handle_invite(
client,
room,
event,
runtime.platform,
runtime.store,
runtime.auth_mgr,
runtime.chat_mgr,
)
assert client.join.await_count == 2
assert client.room_create.await_count == 2
client.room_send.assert_awaited_once()
async def test_bot_ignores_its_own_messages():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock()
room = SimpleNamespace(room_id="!dm:example.org")
event = SimpleNamespace(sender="@bot:example.org", body="hello")
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
bot._send_all.assert_not_awaited()
async def test_bot_degrades_platform_errors_to_user_reply():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(
user_id="@bot:example.org",
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(
side_effect=PlatformError("Missing Authentication header", code="401")
)
room = SimpleNamespace(room_id="!dm:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
client.room_send.assert_awaited_once_with(
"!dm:example.org",
"m.room.message",
{
"msgtype": "m.text",
"body": "Сервис временно недоступен. Попробуйте ещё раз позже.",
},
)
async def test_bot_assigns_platform_chat_id_for_existing_managed_room():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!chat1:example.org",
{"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
)
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat1:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
runtime.dispatcher.dispatch.assert_awaited_once()
async def test_bot_keeps_local_chat_id_for_plain_messages():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!chat1:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "41",
},
)
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat1:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
dispatched = runtime.dispatcher.dispatch.await_args.args[0]
assert dispatched.chat_id == "C1"
assert dispatched.text == "hello"
async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, monkeypatch):
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!chat1:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "41",
},
)
client = SimpleNamespace(
user_id="@bot:example.org",
download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
)
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat1:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="report.pdf",
msgtype="m.file",
replyto_event_id=None,
url="mxc://server/id",
mimetype="application/pdf",
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
staged = await get_staged_attachments(runtime.store, "!chat1:example.org", "@alice:example.org")
assert staged[0]["workspace_path"] is not None
assert (tmp_path / staged[0]["workspace_path"]).read_bytes() == b"%PDF-1.7"
bot._send_all.assert_not_awaited()
async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch):
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
runtime = build_runtime(platform=MockPlatformClient())
runtime.registry = AgentRegistry(
[
AgentDefinition(
agent_id="agent-17",
label="Agent 17",
base_url="http://lambda.coredump.ru:7000/agent_17/",
workspace_path=str(tmp_path / "agents" / "17"),
)
]
)
await set_room_meta(
runtime.store,
"!chat17:example.org",
{
"chat_id": "C17",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "17",
"agent_id": "agent-17",
},
)
client = SimpleNamespace(
user_id="@bot:example.org",
download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
)
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat17:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="report.pdf",
msgtype="m.file",
replyto_event_id=None,
url="mxc://server/id",
mimetype="application/pdf",
)
await bot.on_room_message(room, event)
staged = await get_staged_attachments(
runtime.store, "!chat17:example.org", "@alice:example.org"
)
assert staged[0]["workspace_path"].startswith("incoming/")
assert (
tmp_path / "agents" / "17" / staged[0]["workspace_path"]
).read_bytes() == b"%PDF-1.7"
async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch):
monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
output_file = tmp_path / "agents" / "17" / "output" / "result.txt"
output_file.parent.mkdir(parents=True)
output_file.write_text("ready", encoding="utf-8")
runtime = build_runtime(platform=MockPlatformClient())
runtime.registry = AgentRegistry(
[
AgentDefinition(
agent_id="agent-17",
label="Agent 17",
base_url="http://lambda.coredump.ru:7000/agent_17/",
workspace_path=str(tmp_path / "agents" / "17"),
)
]
)
await set_room_meta(
runtime.store,
"!chat17:example.org",
{
"chat_id": "C17",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "17",
"agent_id": "agent-17",
},
)
client = SimpleNamespace(
user_id="@bot:example.org",
upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})),
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(
return_value=[
OutgoingMessage(
chat_id="C17",
text="Файл готов",
attachments=[
Attachment(
type="document",
filename="result.txt",
mime_type="text/plain",
workspace_path="output/result.txt",
)
],
)
]
)
room = SimpleNamespace(room_id="!chat17:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="сделай отчёт",
msgtype="m.text",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
uploaded_handle = client.upload.await_args.args[0]
assert uploaded_handle.name == str(output_file)
assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result"
async def test_file_only_event_is_staged_and_does_not_dispatch():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
bot._materialize_incoming_attachments = AsyncMock(
return_value=IncomingMessage(
user_id="@alice:example.org",
platform="matrix",
chat_id="!r:example.org",
text="",
attachments=[
Attachment(
type="document",
filename="report.pdf",
workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
mime_type="application/pdf",
)
],
)
)
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="report.pdf",
msgtype="m.file",
url="mxc://hs/id",
mimetype="application/pdf",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
assert [item["filename"] for item in staged] == ["report.pdf"]
client.room_send.assert_not_awaited()
async def test_list_command_returns_current_staged_attachments():
runtime = build_runtime(platform=MockPlatformClient())
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{"filename": "a.pdf", "workspace_path": "a.pdf"},
)
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{"filename": "b.pdf", "workspace_path": "b.pdf"},
)
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
body = client.room_send.await_args.args[2]["body"]
assert "1. a.pdf" in body
assert "2. b.pdf" in body
async def test_remove_invalid_index_returns_short_error():
runtime = build_runtime(platform=MockPlatformClient())
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{"filename": "a.pdf", "workspace_path": "a.pdf"},
)
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
async def test_remove_attachment_updates_list_and_state():
runtime = build_runtime(platform=MockPlatformClient())
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{"filename": "a.pdf", "workspace_path": "a.pdf"},
)
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{"filename": "b.pdf", "workspace_path": "b.pdf"},
)
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org", body="!remove 1", msgtype="m.text", replyto_event_id=None
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
assert [item["filename"] for item in staged] == ["b.pdf"]
body = client.room_send.await_args.args[2]["body"]
assert "1. b.pdf" in body
assert "a.pdf" not in body
async def test_remove_all_clears_state():
runtime = build_runtime(platform=MockPlatformClient())
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{"filename": "a.pdf", "workspace_path": "a.pdf"},
)
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="!remove all",
msgtype="m.text",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
assert client.room_send.await_args.args[2]["body"] == "Все вложения удалены."
async def test_staged_attachment_commands_are_scoped_by_room_and_user():
runtime = build_runtime(platform=MockPlatformClient())
await add_staged_attachment(
runtime.store,
"!r-one:example.org",
"@alice:example.org",
{"filename": "alice-room-one.pdf", "workspace_path": "alice-room-one.pdf"},
)
await add_staged_attachment(
runtime.store,
"!r-two:example.org",
"@alice:example.org",
{"filename": "alice-room-two.pdf", "workspace_path": "alice-room-two.pdf"},
)
await add_staged_attachment(
runtime.store,
"!r-one:example.org",
"@bob:example.org",
{"filename": "bob-room-one.pdf", "workspace_path": "bob-room-one.pdf"},
)
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!r-one:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="!list",
msgtype="m.text",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
body = client.room_send.await_args.args[2]["body"]
assert "alice-room-one.pdf" in body
assert "alice-room-two.pdf" not in body
assert "bob-room-one.pdf" not in body
async def test_next_normal_message_commits_staged_attachments():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!r:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "41",
},
)
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{
"type": "document",
"filename": "report.pdf",
"workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
"mime_type": "application/pdf",
},
)
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="Проанализируй",
msgtype="m.text",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
dispatched = runtime.dispatcher.dispatch.await_args.args[0]
assert isinstance(dispatched, IncomingMessage)
assert dispatched.text == "Проанализируй"
assert [a.workspace_path for a in dispatched.attachments] == [
"surfaces/matrix/alice/r/inbox/report.pdf"
]
assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
async def test_failed_commit_preserves_staged_attachments():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!r:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "41",
},
)
await add_staged_attachment(
runtime.store,
"!r:example.org",
"@alice:example.org",
{
"type": "document",
"filename": "report.pdf",
"workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
},
)
client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
bot = MatrixBot(client, runtime)
runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
room = SimpleNamespace(room_id="!r:example.org")
event = SimpleNamespace(
sender="@alice:example.org",
body="Проанализируй",
msgtype="m.text",
replyto_event_id=None,
)
await bot.on_room_message(room, event)
staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
assert [item["filename"] for item in staged] == ["report.pdf"]
async def test_bot_keeps_commands_on_local_chat_id():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!chat1:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "41",
},
)
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat1:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="!rename New")
await bot.on_room_message(room, event)
dispatched = runtime.dispatcher.dispatch.await_args.args[0]
assert dispatched.chat_id == "C1"
assert dispatched.command == "rename"
async def test_bot_leaves_existing_platform_chat_id_unchanged():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!chat1:example.org",
{
"chat_id": "C1",
"matrix_user_id": "@alice:example.org",
"platform_chat_id": "99",
},
)
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock(return_value=[])
room = SimpleNamespace(room_id="!chat1:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "99"
runtime.dispatcher.dispatch.assert_awaited_once()
async def test_bot_assigns_platform_chat_id_before_load_selection():
runtime = build_runtime(platform=MockPlatformClient())
await set_room_meta(
runtime.store,
"!chat1:example.org",
{"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
)
client = SimpleNamespace(
user_id="@bot:example.org",
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
await set_load_pending(
runtime.store,
"@alice:example.org",
"!chat1:example.org",
{"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
)
room = SimpleNamespace(room_id="!chat1:example.org")
event = SimpleNamespace(sender="@alice:example.org", body="0")
await bot.on_room_message(room, event)
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
client.room_send.assert_awaited_once_with(
"!chat1:example.org",
"m.room.message",
{"msgtype": "m.text", "body": "Отменено."},
)
async def test_unregistered_room_bootstraps_space_and_chat_on_first_message():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
user_id="@bot:example.org",
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
assert client.room_create.await_count == 2
first_call = client.room_create.call_args_list[0]
second_call = client.room_create.call_args_list[1]
assert first_call.kwargs.get("space") is True
assert first_call.kwargs.get("invite") == ["@alice:example.org"]
assert second_call.kwargs.get("name") == "Чат 1"
assert second_call.kwargs.get("invite") == ["@alice:example.org"]
client.room_put_state.assert_awaited_once()
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
assert room_meta is not None
assert room_meta["chat_id"] == "C1"
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None
assert user_meta["space_id"] == "!space:example.org"
room_send_calls = client.room_send.await_args_list
assert any(call.args[0] == "!chat1:example.org" for call in room_send_calls)
assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
assert entry_meta == {
"matrix_user_id": "@alice:example.org",
"redirect_room_id": "!chat1:example.org",
"redirect_chat_id": "C1",
}
async def test_unregistered_room_second_message_reuses_existing_bootstrap():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
user_id="@bot:example.org",
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
await bot.on_room_message(
room, SimpleNamespace(sender="@alice:example.org", body="hello again")
)
assert client.room_create.await_count == 2
room_send_calls = client.room_send.await_args_list
assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
assert any(
call.args[0] == "!entry:example.org"
and "Рабочий чат уже создан: C1" in call.args[2]["body"]
for call in room_send_calls
)
entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
assert entry_meta is not None
assert "platform_chat_id" not in entry_meta
async def test_unregistered_room_welcome_send_failure_does_not_repeat_bootstrap():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
user_id="@bot:example.org",
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_send=AsyncMock(side_effect=[RuntimeError("welcome failed"), None]),
)
bot = MatrixBot(client, runtime)
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
with pytest.raises(RuntimeError, match="welcome failed"):
await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
assert entry_meta == {
"matrix_user_id": "@alice:example.org",
"redirect_room_id": "!chat1:example.org",
"redirect_chat_id": "C1",
}
await bot.on_room_message(
room, SimpleNamespace(sender="@alice:example.org", body="hello again")
)
assert client.room_create.await_count == 2
room_send_calls = client.room_send.await_args_list
assert any(
call.args[0] == "!entry:example.org"
and "Рабочий чат уже создан: C1" in call.args[2]["body"]
for call in room_send_calls
)
async def test_unregistered_room_creates_new_chat_in_existing_space():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(
runtime.store,
"@alice:example.org",
{"space_id": "!space:example.org", "next_chat_index": 4},
)
chat_resp = SimpleNamespace(room_id="!chat4:example.org")
client = SimpleNamespace(
user_id="@bot:example.org",
room_create=AsyncMock(return_value=chat_resp),
room_put_state=AsyncMock(),
room_send=AsyncMock(),
)
bot = MatrixBot(client, runtime)
room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
event = SimpleNamespace(sender="@alice:example.org", body="hello")
await bot.on_room_message(room, event)
client.room_create.assert_awaited_once_with(
name="Чат 4",
visibility=RoomVisibility.private,
is_direct=False,
invite=["@alice:example.org"],
)
client.room_put_state.assert_awaited_once()
room_meta = await get_room_meta(runtime.store, "!chat4:example.org")
assert room_meta is not None
assert room_meta["chat_id"] == "C4"
async def test_mat11_settings_returns_mvp_unavailable_message():
runtime = build_runtime(platform=MockPlatformClient())
current_chat_id = "C9"
start = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
)
await runtime.dispatcher.dispatch(start)
settings_cmd = IncomingCommand(
user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings"
)
result = await runtime.dispatcher.dispatch(settings_cmd)
assert len(result) == 1
text = result[0].text
assert "недоступна" in text.lower()
assert "mvp" in text.lower()
async def test_mat12_help_returns_command_reference():
runtime = build_runtime(platform=MockPlatformClient())
result = await runtime.dispatcher.dispatch(
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="help")
)
assert len(result) == 1
text = result[0].text
assert "!new" in text
assert "!chats" in text
assert "!rename" in text
assert "!archive" in text
assert "!clear" in text
assert "!list" in text
assert "!yes" in text
assert "!context" not in text
assert "!save" not in text
assert "!load" not in text
assert "!agent" not in text
assert "!settings" not in text
assert "!skills" not in text
async def test_unknown_command_returns_helpful_message():
runtime = build_runtime(platform=MockPlatformClient())
result = await runtime.dispatcher.dispatch(
IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="clear")
)
assert len(result) == 1
assert "неизвестная команда" in result[0].text.lower()
async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
client = SimpleNamespace(
sync=AsyncMock(
return_value=SyncResponse(
next_batch="s123",
rooms={},
device_key_count={},
device_list=SimpleNamespace(changed=[], left=[]),
to_device_events=[],
presence_events=[],
account_data_events=[],
)
)
)
since = await prepare_live_sync(client)
client.sync.assert_awaited_once_with(timeout=0, full_state=True)
assert since == "s123"
async def test_build_runtime_uses_routed_platform_when_matrix_backend_is_real(
monkeypatch, tmp_path
):
registry_path = tmp_path / "agents.yaml"
registry_path.write_text(
"agents:\n - id: agent-1\n label: Analyst\n", encoding="utf-8"
)
monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
runtime = build_runtime()
assert isinstance(runtime.platform, RoutedPlatformClient)
async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):
bot_module = importlib.import_module("adapter.matrix.bot")
platform_close = AsyncMock()
runtime = SimpleNamespace(platform=SimpleNamespace(close=platform_close))
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
self.access_token = None
self.callbacks = []
self.sync_forever = AsyncMock()
self.close = AsyncMock()
async def login(self, *args, **kwargs):
raise AssertionError("login should not be called when access token is provided")
def add_event_callback(self, callback, event_type):
self.callbacks.append((callback, event_type))
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
await bot_module.main()
platform_close.assert_awaited_once()
async def test_matrix_main_registers_media_message_callbacks(monkeypatch):
bot_module = importlib.import_module("adapter.matrix.bot")
runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
created_clients = []
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
self.access_token = None
self.callbacks = []
self.sync_forever = AsyncMock()
self.close = AsyncMock()
created_clients.append(self)
async def login(self, *args, **kwargs):
raise AssertionError("login should not be called when access token is provided")
def add_event_callback(self, callback, event_type):
self.callbacks.append((callback, event_type))
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
await bot_module.main()
assert len(created_clients) == 1
registered_types = [event_type for _, event_type in created_clients[0].callbacks]
assert (
RoomMessageText,
RoomMessageFile,
RoomMessageImage,
RoomMessageVideo,
RoomMessageAudio,
) in registered_types