surfaces/.planning/phases/01-matrix-qa-polish/01-04-PLAN.md

32 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-matrix-qa-polish 04 execute 3
01-01
01-02
01-03
tests/adapter/matrix/test_dispatcher.py
tests/adapter/matrix/test_reactions.py
tests/adapter/matrix/test_store.py
tests/adapter/matrix/test_invite_space.py
tests/adapter/matrix/test_chat_space.py
tests/adapter/matrix/test_send_outgoing.py
tests/adapter/matrix/test_confirm.py
true
truths artifacts key_links
All 4 previously-broken tests are fixed and green
12 new tests (MAT-01..MAT-12) are implemented and green
pytest tests/ -q shows 96+ tests passing
No test uses hardcoded 'C1' assumption from old DM flow
path provides contains
tests/adapter/matrix/test_invite_space.py MAT-01, MAT-02, MAT-03 tests space=True
path provides contains
tests/adapter/matrix/test_chat_space.py MAT-04, MAT-05, MAT-10, MAT-12 tests room_put_state
path provides contains
tests/adapter/matrix/test_send_outgoing.py MAT-06, MAT-07 tests !yes
path provides contains
tests/adapter/matrix/test_confirm.py MAT-09 test get_pending_confirm
path provides
tests/adapter/matrix/test_dispatcher.py Fixed broken tests + MAT-11
path provides
tests/adapter/matrix/test_reactions.py Fixed broken tests
path provides contains
tests/adapter/matrix/test_store.py MAT-08 pending_confirm roundtrip test pending_confirm
from to via pattern
tests/adapter/matrix/test_invite_space.py adapter/matrix/handlers/auth.py tests handle_invite handle_invite
from to via pattern
tests/adapter/matrix/test_chat_space.py adapter/matrix/handlers/chat.py tests make_handle_new_chat make_handle_new_chat
Fix all broken tests and implement 12 new test cases (MAT-01..MAT-12) covering the Space+rooms refactor.

Purpose: Achieve 96+ green tests as required by Phase 1 deliverables. Currently 97 pass; 4 will break from Plans 01-03 changes. This plan fixes those 4 and adds 12 new, targeting ~109 total.

Output: Full green test suite with comprehensive Space+rooms coverage.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/01-matrix-qa-polish/01-CONTEXT.md @.planning/phases/01-matrix-qa-polish/01-RESEARCH.md @.planning/phases/01-matrix-qa-polish/01-VALIDATION.md

@tests/adapter/matrix/test_dispatcher.py @tests/adapter/matrix/test_reactions.py @tests/adapter/matrix/test_store.py @adapter/matrix/handlers/auth.py @adapter/matrix/handlers/chat.py @adapter/matrix/handlers/confirm.py @adapter/matrix/handlers/settings.py @adapter/matrix/bot.py @adapter/matrix/store.py @adapter/matrix/reactions.py @adapter/matrix/converter.py @core/protocol.py

# adapter/matrix/handlers/auth.py
async def handle_invite(client, room, event, platform, store, auth_mgr) -> None
# Creates Space (space=True), chat room, room_put_state, room_invite x2, stores user_meta+room_meta

# adapter/matrix/store.py
async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None
async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None
async def clear_pending_confirm(store: StateStore, room_id: str) -> None

# adapter/matrix/handlers/chat.py
def make_handle_new_chat(client, store) -> Callable  # closure factory
def make_handle_archive(client, store) -> Callable   # closure factory
def make_handle_rename(client, store) -> Callable     # closure factory

# adapter/matrix/handlers/confirm.py
def make_handle_confirm(store=None) -> Callable  # closure factory
def make_handle_cancel(store=None) -> Callable   # closure factory

# adapter/matrix/bot.py
async def send_outgoing(client, room_id, event, store=None) -> None
# For OutgoingUI: renders text + "!yes/!no", calls set_pending_confirm

# adapter/matrix/reactions.py
def build_skills_text(settings) -> str  # No longer mentions "Реакции 1-9"
def build_confirmation_text(description) -> str  # Uses "!yes/!no" not emojis
class InMemoryStore:
    async def get(key) -> Any
    async def set(key, value) -> None
    async def delete(key) -> None  # Check if exists; if not, use set(key, None)
class MockPlatformClient:
    # Provides get_or_create_user, get_settings, etc.
@dataclass
class UserSettings:
    skills: dict
    connectors: dict
    soul: dict
    safety: dict
    plan: dict
Task 1: Fix 4 broken tests in test_dispatcher.py and test_reactions.py tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_reactions.py tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_reactions.py, adapter/matrix/handlers/auth.py, adapter/matrix/handlers/chat.py, adapter/matrix/bot.py, adapter/matrix/reactions.py, adapter/matrix/store.py **Fix 1: test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome**

The old test checks client.join and meta["chat_id"] == "C1" via room_meta on the DM room. After refactor, handle_invite creates a Space + chat room, so the test needs different mocks and assertions.

Replace the entire test function with:

async def test_invite_event_creates_space_and_chat_room():
    from adapter.matrix.store import get_user_meta, get_room_meta

    runtime = build_runtime(platform=MockPlatformClient())
    # Mock client with room_create, room_put_state, room_invite, room_send, join
    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)

    # Verify Space created with space=True
    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)

    # Verify room_put_state called to add child to Space
    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"

    # Verify user_meta has space_id
    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"

    # Verify room_meta for chat room
    room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
    assert room_meta is not None
    assert room_meta["chat_id"] == "C1"
    assert room_meta["space_id"] == "!space:example.org"

    # Verify auth confirmed
    assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True

    # Verify welcome message sent
    client.room_send.assert_awaited_once()

Also add import at top if not present:

from adapter.matrix.store import get_user_meta, get_room_meta

(get_room_meta is already imported)

Fix 2: test_dispatcher.py::test_invite_event_is_idempotent_per_room

This test now needs to check idempotency on user_meta (not room_meta). Replace with:

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)
    # Second call should be a no-op (user already has space_id)
    await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)

    # room_create called only twice (once for Space, once for chat room) — not 4 times
    assert client.room_create.await_count == 2

Fix 3: test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available

After refactor, make_handle_new_chat needs space_id in user_meta and calls room_put_state. Update:

async def test_new_chat_creates_real_matrix_room_when_client_available():
    from adapter.matrix.store import set_user_meta

    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)

    # Pre-populate user_meta with space_id (as if invite flow already ran)
    await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 1})

    start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
    await runtime.dispatcher.dispatch(start)

    new = IncomingCommand(
        user_id="u1",
        platform="matrix",
        chat_id="C1",
        command="new",
        args=["Research"],
    )
    result = await runtime.dispatcher.dispatch(new)

    client.room_create.assert_awaited_once_with(name="Research", visibility="private", is_direct=False)
    client.room_put_state.assert_awaited_once()
    # Verify room_put_state adds child to space
    put_call = client.room_put_state.call_args
    assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"

    assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)

Fix 4: test_dispatcher.py::test_matrix_dispatcher_registers_custom_handlers

This test checks "Реакции 1⃣-9⃣" in r.text on line 39. After reactions removal, this string no longer appears. Update:

Change line 39 from:

    assert any(isinstance(r, OutgoingMessage) and "Реакции 1⃣-9⃣" in r.text for r in result)

to:

    assert any(isinstance(r, OutgoingMessage) and "!skill on/off" in r.text for r in result)

Fix 5: test_reactions.py::test_build_skills_text

Change assertion from:

    assert "Реакции 1⃣-9⃣" in text

to:

    assert "!skill on/off" in text

Fix 6: test_reactions.py::test_build_confirmation_text

The old test checks for "подтвердить" which may still be in the text. Update to check for new format:

def test_build_confirmation_text():
    text = build_confirmation_text("Отправить письмо?")
    assert "Отправить письмо?" in text
    assert "!yes" in text
    assert "!no" in text

Also make sure the get_room_meta import and get_user_meta import are present in test_dispatcher.py. Add from adapter.matrix.store import get_user_meta, set_user_meta if not already imported. cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_reactions.py -x -q 2>&1 | tail -5 <acceptance_criteria>

  • test_dispatcher.py does NOT contain test_invite_event_creates_dm_room_and_sends_welcome (renamed to test_invite_event_creates_space_and_chat_room)
  • test_dispatcher.py contains test_invite_event_creates_space_and_chat_room
  • test_dispatcher.py contains space=True in assertions
  • test_dispatcher.py contains room_put_state in assertions
  • test_reactions.py contains !skill on/off instead of Реакции 1
  • test_reactions.py contains !yes in confirmation text test
  • pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_reactions.py -x -q passes </acceptance_criteria> All 4 previously-broken tests fixed and passing (renamed/updated for Space+rooms)
Task 2: Create new test files and implement MAT-01..MAT-12 tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_send_outgoing.py, tests/adapter/matrix/test_confirm.py, tests/adapter/matrix/test_store.py, tests/adapter/matrix/test_dispatcher.py adapter/matrix/handlers/auth.py, adapter/matrix/handlers/chat.py, adapter/matrix/bot.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py, adapter/matrix/store.py, tests/adapter/matrix/test_store.py, tests/adapter/matrix/test_dispatcher.py Create 4 new test files and extend 2 existing ones. All tests use `pytest-asyncio` (async test functions are auto-detected).

File 1: tests/adapter/matrix/test_invite_space.py (MAT-01, MAT-02, MAT-03)

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock

from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_user_meta, get_room_meta
from adapter.matrix.bot import build_runtime
from sdk.mock import MockPlatformClient


def _make_client():
    """Helper: create mock client with Space+room creation responses."""
    space_resp = SimpleNamespace(room_id="!space:example.org")
    chat_resp = SimpleNamespace(room_id="!chat1:example.org")
    return SimpleNamespace(
        join=AsyncMock(),
        room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
        room_put_state=AsyncMock(),
        room_invite=AsyncMock(),
        room_send=AsyncMock(),
    )


async def test_mat01_invite_creates_space_and_chat1():
    """MAT-01: handle_invite creates Space + Чат 1, saves space_id in user_meta."""
    runtime = build_runtime(platform=MockPlatformClient())
    client = _make_client()
    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)

    # Space created with space=True
    first_call = client.room_create.call_args_list[0]
    assert first_call.kwargs.get("space") is True

    # Chat room created
    assert client.room_create.await_count == 2

    # room_put_state links child to Space
    client.room_put_state.assert_awaited_once()
    ps_kwargs = client.room_put_state.call_args.kwargs
    assert ps_kwargs.get("event_type") == "m.space.child"
    assert ps_kwargs.get("state_key") == "!chat1:example.org"
    assert ps_kwargs.get("room_id") == "!space:example.org"

    # user_meta stores space_id
    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_meta stores chat metadata
    room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
    assert room_meta is not None
    assert room_meta["chat_id"] == "C1"
    assert room_meta["space_id"] == "!space:example.org"


async def test_mat02_invite_idempotent():
    """MAT-02: Repeated invite does not create second Space."""
    runtime = build_runtime(platform=MockPlatformClient())
    client = _make_client()
    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)

    # Reset side_effect for potential second call
    client.room_create.side_effect = None
    client.room_create.return_value = SimpleNamespace(room_id="!should-not-exist:example.org")

    await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)

    # Still only 2 room_create calls (from first invite)
    assert client.room_create.await_count == 2


async def test_mat03_no_hardcoded_c1():
    """MAT-03: handle_invite uses next_chat_id, not hardcoded 'C1'."""
    import ast
    import inspect
    source = inspect.getsource(handle_invite)
    # Check that the literal string '"C1"' or "'C1'" does not appear as a value assignment
    assert '"C1"' not in source or "chat_id" not in source.split('"C1"')[0].split("\n")[-1]
    # More robust: verify via actual behavior — chat_id comes from next_chat_id
    runtime = build_runtime(platform=MockPlatformClient())
    client = _make_client()
    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)

    room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
    # C1 is correct for first user, but it came from next_chat_id (not hardcode)
    assert room_meta["chat_id"] == "C1"

    # Verify next_chat_index was incremented (proves next_chat_id was used)
    user_meta = await get_user_meta(runtime.store, "@alice:example.org")
    assert user_meta["next_chat_index"] == 2  # Incremented from 1 to 2

File 2: tests/adapter/matrix/test_chat_space.py (MAT-04, MAT-05, MAT-10, MAT-12)

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock

from nio.responses import RoomCreateError

from adapter.matrix.handlers.chat import make_handle_new_chat, make_handle_archive
from adapter.matrix.store import set_user_meta
from core.protocol import IncomingCommand, OutgoingMessage
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from sdk.mock import MockPlatformClient


async def _setup():
    """Helper: create platform, store, managers, authenticate user."""
    platform = MockPlatformClient()
    store = InMemoryStore()
    chat_mgr = ChatManager(platform, store)
    auth_mgr = AuthManager(platform, store)
    settings_mgr = SettingsManager(platform, store)
    await auth_mgr.confirm("@alice:example.org")
    return platform, store, chat_mgr, auth_mgr, settings_mgr


async def test_mat04_new_chat_calls_room_put_state_with_space_id():
    """MAT-04: !new calls room_put_state to add room to Space."""
    platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
    await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2})

    client = SimpleNamespace(
        room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")),
        room_put_state=AsyncMock(),
        room_invite=AsyncMock(),
    )
    handler = make_handle_new_chat(client, store)
    event = IncomingCommand(
        user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new", args=["Test"]
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    client.room_put_state.assert_awaited_once()
    ps_kwargs = client.room_put_state.call_args.kwargs
    assert ps_kwargs.get("room_id") == "!space:ex"
    assert ps_kwargs.get("event_type") == "m.space.child"
    assert ps_kwargs.get("state_key") == "!newroom:ex"
    assert any(isinstance(r, OutgoingMessage) and "Test" in r.text for r in result)


async def test_mat05_new_chat_without_space_id_returns_error():
    """MAT-05: !new without space_id in user_meta returns error message."""
    platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
    # user_meta exists but no space_id
    await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1})

    client = SimpleNamespace(
        room_create=AsyncMock(),
        room_put_state=AsyncMock(),
        room_invite=AsyncMock(),
    )
    handler = make_handle_new_chat(client, store)
    event = IncomingCommand(
        user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new"
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    # Should return error, not crash
    assert len(result) == 1
    assert isinstance(result[0], OutgoingMessage)
    assert "Space" in result[0].text or "ошибка" in result[0].text.lower() or "Ошибка" in result[0].text
    # room_create should NOT have been called
    client.room_create.assert_not_awaited()


async def test_mat10_archive_calls_chat_mgr_archive():
    """MAT-10: !archive archives chat via chat_mgr.archive (Space removal deferred)."""
    platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()

    handler = make_handle_archive(None, store)
    event = IncomingCommand(
        user_id="@alice:example.org", platform="matrix", chat_id="C1", command="archive"
    )
    # Create a chat first so archive has something to work with
    await chat_mgr.get_or_create(
        user_id="@alice:example.org", chat_id="C1", platform="matrix",
        surface_ref="!room:ex", name="Test"
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    assert len(result) == 1
    assert "архивирован" in result[0].text


async def test_mat12_room_create_error_returns_user_message():
    """MAT-12: RoomCreateError is handled gracefully with user-facing message."""
    platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
    await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2})

    # Simulate RoomCreateError
    error_resp = RoomCreateError(message="rate limited", status_code="429")
    client = SimpleNamespace(
        room_create=AsyncMock(return_value=error_resp),
        room_put_state=AsyncMock(),
        room_invite=AsyncMock(),
    )
    handler = make_handle_new_chat(client, store)
    event = IncomingCommand(
        user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new", args=["Fail"]
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    assert len(result) == 1
    assert isinstance(result[0], OutgoingMessage)
    assert "Не удалось" in result[0].text or "не удалось" in result[0].text
    # room_put_state should NOT have been called (room creation failed)
    client.room_put_state.assert_not_awaited()

NOTE: For MAT-12, RoomCreateError constructor signature may differ. Check the actual nio source. It might be RoomCreateError(message="...", status_code="...") or just RoomCreateError(message="..."). If the constructor fails, create a mock:

error_resp = SimpleNamespace(status_code="429")  # Duck-typing: no room_id attr

and rely on isinstance(resp, RoomCreateError) check in the handler. If isinstance check is used, the SimpleNamespace won't match — so use the real class or mock it. Actually, the handler uses isinstance(resp, RoomCreateError) so we MUST use a real RoomCreateError instance or the check won't match. Try both approaches:

  • First: RoomCreateError(message="error")
  • If that fails: mock the isinstance check by making room_create return an object where hasattr(resp, 'room_id') is False

Read nio/responses.py source to find the exact constructor if RoomCreateError(message="error") fails during test execution.

File 3: tests/adapter/matrix/test_send_outgoing.py (MAT-06, MAT-07)

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock

from adapter.matrix.bot import send_outgoing
from adapter.matrix.store import get_pending_confirm
from core.protocol import OutgoingUI, UIButton
from core.store import InMemoryStore


async def test_mat06_outgoing_ui_renders_text_with_yes_no():
    """MAT-06: OutgoingUI renders as text + '!yes / !no' hint."""
    client = SimpleNamespace(room_send=AsyncMock())
    store = InMemoryStore()
    event = OutgoingUI(
        chat_id="C1",
        text="Удалить файл?",
        buttons=[UIButton(label="Подтвердить", action="confirm")],
    )

    await send_outgoing(client, "!room:ex", event, store=store)

    client.room_send.assert_awaited_once()
    call_args = client.room_send.call_args
    body = call_args.args[2]["body"] if len(call_args.args) > 2 else call_args.kwargs.get("content", {}).get("body", "")
    assert "Удалить файл?" in body
    assert "!yes" in body
    assert "!no" in body
    assert "Подтвердить" in body


async def test_mat07_outgoing_ui_no_reaction_sent():
    """MAT-07: OutgoingUI does NOT send m.reaction event."""
    client = SimpleNamespace(room_send=AsyncMock())
    store = InMemoryStore()
    event = OutgoingUI(
        chat_id="C1",
        text="Confirm action?",
        buttons=[UIButton(label="OK", action="confirm")],
    )

    await send_outgoing(client, "!room:ex", event, store=store)

    # Only one room_send call (the text message), no m.reaction
    assert client.room_send.await_count == 1
    call_args = client.room_send.call_args
    msg_type = call_args.args[1] if len(call_args.args) > 1 else ""
    assert msg_type == "m.room.message"
    # Verify no m.reaction calls
    for call in client.room_send.call_args_list:
        assert call.args[1] != "m.reaction"

File 4: tests/adapter/matrix/test_confirm.py (MAT-09)

from __future__ import annotations

from adapter.matrix.handlers.confirm import make_handle_confirm, make_handle_cancel
from adapter.matrix.store import set_pending_confirm, get_pending_confirm
from core.protocol import IncomingCallback, OutgoingMessage
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager


async def test_mat09_yes_reads_pending_confirm():
    """MAT-09: !yes reads pending_confirm and returns action description."""
    store = InMemoryStore()
    platform = MockPlatformClient()
    chat_mgr = ChatManager(platform, store)
    auth_mgr = AuthManager(platform, store)
    settings_mgr = SettingsManager(platform, store)

    # Set up pending confirmation
    await set_pending_confirm(store, "C1", {
        "action_id": "delete_file",
        "description": "Удалить файл config.yaml",
        "payload": {},
    })

    handler = make_handle_confirm(store)
    event = IncomingCallback(
        user_id="@alice:example.org",
        platform="matrix",
        chat_id="C1",
        action="confirm",
        payload={"source": "command", "command": "yes"},
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    assert len(result) == 1
    assert isinstance(result[0], OutgoingMessage)
    assert "Удалить файл config.yaml" in result[0].text

    # pending_confirm should be cleared after confirmation
    pending = await get_pending_confirm(store, "C1")
    assert pending is None


async def test_no_clears_pending_confirm():
    """!no clears pending_confirm and returns cancellation."""
    store = InMemoryStore()
    platform = MockPlatformClient()
    chat_mgr = ChatManager(platform, store)
    auth_mgr = AuthManager(platform, store)
    settings_mgr = SettingsManager(platform, store)

    await set_pending_confirm(store, "C1", {
        "action_id": "delete_file",
        "description": "Удалить файл",
        "payload": {},
    })

    handler = make_handle_cancel(store)
    event = IncomingCallback(
        user_id="@alice:example.org",
        platform="matrix",
        chat_id="C1",
        action="cancel",
        payload={"source": "command", "command": "no"},
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    assert len(result) == 1
    assert "отменено" in result[0].text.lower()

    pending = await get_pending_confirm(store, "C1")
    assert pending is None


async def test_yes_without_pending_returns_no_pending():
    """!yes with no pending confirmation returns 'no pending' message."""
    store = InMemoryStore()
    platform = MockPlatformClient()
    chat_mgr = ChatManager(platform, store)
    auth_mgr = AuthManager(platform, store)
    settings_mgr = SettingsManager(platform, store)

    handler = make_handle_confirm(store)
    event = IncomingCallback(
        user_id="@alice:example.org",
        platform="matrix",
        chat_id="C1",
        action="confirm",
        payload={},
    )
    result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)

    assert len(result) == 1
    assert "Нет ожидающих" in result[0].text

File 5: Extend tests/adapter/matrix/test_store.py (MAT-08)

Add at the end of the existing file:

async def test_pending_confirm_roundtrip(store: InMemoryStore):
    """MAT-08: get/set/clear_pending_confirm roundtrip."""
    from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm

    # Initially None
    assert await get_pending_confirm(store, "!room:m.org") is None

    # Set
    meta = {"action_id": "test", "description": "Do thing"}
    await set_pending_confirm(store, "!room:m.org", meta)
    assert await get_pending_confirm(store, "!room:m.org") == meta

    # Clear
    await clear_pending_confirm(store, "!room:m.org")
    assert await get_pending_confirm(store, "!room:m.org") is None

File 6: Extend tests/adapter/matrix/test_dispatcher.py (MAT-11)

Add at the end of test_dispatcher.py:

async def test_mat11_settings_returns_dashboard():
    """MAT-11: !settings returns a read-only dashboard with status info."""
    runtime = build_runtime(platform=MockPlatformClient())

    # Authenticate user first
    start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
    await runtime.dispatcher.dispatch(start)

    settings_cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="settings")
    result = await runtime.dispatcher.dispatch(settings_cmd)

    assert len(result) >= 1
    text = result[0].text
    # Dashboard should contain section headers
    assert "Скиллы" in text or "скиллы" in text.lower()
    assert "Изменить" in text or "!skills" in text
    # Should NOT be the old command list format
    assert "!connectors" not in text
    assert "!whoami" not in text

IMPORTANT: Check that core/store.py InMemoryStore has a delete method. If it does NOT, the clear_pending_confirm function will fail. Read core/store.py and if delete is missing, implement clear_pending_confirm using store.set(key, None) instead and update the test accordingly. cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/ -x -q 2>&1 | tail -10 <acceptance_criteria>

  • File tests/adapter/matrix/test_invite_space.py exists and contains test_mat01, test_mat02, test_mat03
  • File tests/adapter/matrix/test_chat_space.py exists and contains test_mat04, test_mat05, test_mat10, test_mat12
  • File tests/adapter/matrix/test_send_outgoing.py exists and contains test_mat06, test_mat07
  • File tests/adapter/matrix/test_confirm.py exists and contains test_mat09
  • tests/adapter/matrix/test_store.py contains test_pending_confirm_roundtrip
  • tests/adapter/matrix/test_dispatcher.py contains test_mat11_settings_returns_dashboard
  • pytest tests/adapter/matrix/ -x -q passes with 0 failures
  • pytest tests/ -q shows 96+ tests passing </acceptance_criteria> All 12 new tests (MAT-01..MAT-12) implemented and green; 4 broken tests fixed; total 96+ passing
After both tasks: - `pytest tests/ -q` shows 96+ tests passing, 0 failures - `pytest tests/adapter/matrix/ -q` shows all Matrix tests passing - New test files exist: test_invite_space.py, test_chat_space.py, test_send_outgoing.py, test_confirm.py

<success_criteria>

  • 96+ tests passing in full suite
  • 4 broken tests fixed (renamed/updated for Space model)
  • 12 new tests implemented covering MAT-01..MAT-12
  • No test references hardcoded "C1" from old DM flow
  • All test files importable and runnable </success_criteria>
After completion, create `.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md`