Serialize Matrix chat sends

This commit is contained in:
Mikhail Putilovskij 2026-04-19 17:18:32 +03:00
parent 4533118b68
commit 17d580096b
4 changed files with 281 additions and 79 deletions

View file

@ -44,7 +44,7 @@ async def test_matrix_dispatcher_registers_custom_handlers():
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 "!skill on/off" in r.text for r in result)
assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
toggle = IncomingCallback(
user_id="u1",
@ -54,7 +54,7 @@ async def test_matrix_dispatcher_registers_custom_handlers():
payload={"skill_index": 2},
)
result = await runtime.dispatcher.dispatch(toggle)
assert any(isinstance(r, OutgoingMessage) and "fetch-url" in r.text for r in result)
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():
@ -226,7 +226,75 @@ async def test_bot_degrades_platform_errors_to_user_reply():
)
async def test_mat11_settings_returns_dashboard():
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)
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"
@ -238,15 +306,10 @@ async def test_mat11_settings_returns_dashboard():
)
result = await runtime.dispatcher.dispatch(settings_cmd)
assert len(result) >= 1
assert len(result) == 1
text = result[0].text
assert "Скиллы" in text or "скиллы" in text.lower()
assert "Личность" in text
assert "Безопасность" in text
assert "Активные чаты" in text
assert "Изменить" not in text
assert "!connectors" not in text
assert "!whoami" not in text
assert "недоступна" in text.lower()
assert "mvp" in text.lower()
async def test_mat12_help_returns_command_reference():
@ -259,10 +322,26 @@ async def test_mat12_help_returns_command_reference():
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 "!settings" in text
assert "!yes" in text
assert "!context" in text
assert "!save" in text
assert "!load" in text
assert "!reset" 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():
@ -302,3 +381,41 @@ async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monk
assert isinstance(runtime.platform, RealPlatformClient)
assert runtime.platform.agent_api.url == "ws://agent.example/agent_ws/"
async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):
bot_module = importlib.import_module("adapter.matrix.bot")
platform_close = AsyncMock()
agent_connect = AsyncMock()
runtime = SimpleNamespace(
platform=SimpleNamespace(
close=platform_close,
agent_api=SimpleNamespace(connect=agent_connect),
)
)
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()
agent_connect.assert_not_awaited()
platform_close.assert_awaited_once()

View file

@ -61,6 +61,35 @@ class LegacyAgentApi:
self.last_tokens_used = 7
class BlockingChatAgentApi:
def __init__(self, chat_id: str) -> None:
self.chat_id = chat_id
self.calls: list[str] = []
self.connect_calls = 0
self.close_calls = 0
self.last_tokens_used = 0
self.active_calls = 0
self.max_active_calls = 0
self.started = asyncio.Event()
self.release = asyncio.Event()
async def connect(self) -> None:
self.connect_calls += 1
async def close(self) -> None:
self.close_calls += 1
async def send_message(self, text: str):
self.calls.append(text)
self.active_calls += 1
self.max_active_calls = max(self.max_active_calls, self.active_calls)
self.started.set()
await self.release.wait()
self.active_calls -= 1
yield FakeChunk(text)
self.last_tokens_used = len(text)
def test_agent_api_wrapper_uses_modern_constructor_when_available(monkeypatch):
calls: list[dict[str, object]] = []
@ -263,6 +292,42 @@ async def test_real_platform_client_creates_distinct_clients_per_chat():
assert agent_api.instances["chat-2"].calls == ["world"]
@pytest.mark.asyncio
async def test_real_platform_client_serializes_same_chat_streams_across_send_paths():
agent_api = FakeAgentApiFactory()
agent_api.instances["chat-1"] = BlockingChatAgentApi("chat-1")
agent_api.for_chat = lambda chat_id: agent_api.instances.setdefault(chat_id, BlockingChatAgentApi(chat_id))
client = RealPlatformClient(
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
async def consume_stream():
chunks = []
async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"):
chunks.append(chunk)
return chunks
stream_task = asyncio.create_task(consume_stream())
await asyncio.wait_for(agent_api.instances["chat-1"].started.wait(), timeout=1)
send_task = asyncio.create_task(client.send_message("@alice:example.org", "chat-1", "again"))
await asyncio.sleep(0)
assert agent_api.instances["chat-1"].calls == ["hello"]
assert agent_api.instances["chat-1"].max_active_calls == 1
agent_api.instances["chat-1"].release.set()
stream_chunks = await stream_task
send_result = await send_task
assert [chunk.delta for chunk in stream_chunks] == ["hello", ""]
assert send_result.response == "again"
assert agent_api.instances["chat-1"].calls == ["hello", "again"]
assert agent_api.instances["chat-1"].max_active_calls == 1
@pytest.mark.asyncio
async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
agent_api = FakeAgentApiFactory()