From be8bc911e006da0e9a5e6288316bb3b896c766c6 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Thu, 2 Apr 2026 18:08:12 +0300 Subject: [PATCH] =?UTF-8?q?docs(phase-01):=20research=20Matrix=20QA=20&=20?= =?UTF-8?q?Polish=20=E2=80=94=20Space+rooms,=20!yes/!no,=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phases/01-matrix-qa-polish/01-RESEARCH.md | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 .planning/phases/01-matrix-qa-polish/01-RESEARCH.md diff --git a/.planning/phases/01-matrix-qa-polish/01-RESEARCH.md b/.planning/phases/01-matrix-qa-polish/01-RESEARCH.md new file mode 100644 index 0000000..3ac72d2 --- /dev/null +++ b/.planning/phases/01-matrix-qa-polish/01-RESEARCH.md @@ -0,0 +1,528 @@ +# Phase 1: Matrix QA & Polish — Research + +**Researched:** 2026-04-02 +**Domain:** matrix-nio AsyncClient — Space+rooms architecture, OutgoingUI text rendering, !yes/!no confirmation flow +**Confidence:** HIGH (all critical APIs verified against the installed library) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Space+rooms — единственная поддерживаемая модель. DM-first убрать. +- **D-02:** При первом invite бот создаёт Space `Lambda — {display_name}`, внутри — первую комнату `Чат 1`. Приглашает пользователя. +- **D-03:** `!new [name]` создаёт новую комнату внутри Space пользователя, приглашает его туда. +- **D-04:** `!archive` выводит комнату из Space (не удаляет). +- **D-05:** Маппинг `room_id → space_id` + `room_id → chat_id` хранится в SQLite через `adapter/matrix/store.py`. +- **D-06:** Реакции (`👍`/`❌`) — убрать полностью. `OutgoingUI` с кнопками рендерится как текст + `!yes` / `!no`. +- **D-07:** Когда агент запрашивает подтверждение, бот пишет текстовое сообщение с описанием и подсказкой: `Ответьте !yes для подтверждения или !no для отмены.` +- **D-08:** `!yes` / `!no` работают в текущей комнате. Состояние ожидания подтверждения хранится per (user_id, room_id). +- **D-09:** Все команды работают из любой комнаты Space — нет выделенной комнаты «Настройки». +- **D-10:** Команды: `!new [name]`, `!chats`, `!rename `, `!archive`, `!skills`, `!soul`, `!safety`, `!settings`, `!yes`, `!no`. +- **D-11:** `!start` — не нужен, онбординг через invite flow. +- **D-12:** `!settings` — read-only дашборд: одно сообщение со статусом всего (скиллы, soul, safety, активные чаты). Ничего не меняет. +- **D-13:** Изменения через субкоманды: `!skills`, `!skill on/off `, `!soul`, `!soul name/style/priority/reset `, `!safety`, `!safety on/off `. +- **D-14:** Каждая команда без аргументов (`!skills`, `!soul`, `!safety`) выводит текущее состояние + подсказку как менять. + +### Claude's Discretion + +- Формат текста в Matrix (plain text vs markdown) — на усмотрение, Matrix клиенты рендерят markdown +- Структура invite-сообщения в новом Space — на усмотрение, главное: приветствие + список команд +- Обработка ошибок matrix-nio (room_create fail, join fail) — логировать + ответить пользователю + +### Deferred Ideas (OUT OF SCOPE) + +- Комната «Настройки» как отдельная закреплённая комната — решили не делать в Phase 1 +- E2EE / python-olm — инфраструктурный трек, вне scope +- Space discovery (бот находит существующий Space при повторном invite) — Phase 2+ +- Attachment handling (m.file, m.image, m.audio) — Phase 2+ + + +--- + +## Summary + +Phase 1 переписывает Matrix адаптер с DM-first на Space+rooms модель, убирает реакции в пользу `!yes`/`!no`, и реализует все команды управления. Большая часть бизнес-логики уже работает через `core/handlers/` и `adapter/matrix/handlers/settings.py`. Главная работа — в трёх точках: `handle_invite` (создание Space + двух комнат), `make_handle_new_chat` (добавление комнаты в Space), и `send_outgoing` (убрать реакции, добавить pending-state для `!yes`/`!no`). + +Текущее состояние: 97 тестов зелёные. Для "96+ зелёных" после рефакторинга нужно обновить 3 существующих теста (они проверяют DM-поведение и реакции) и добавить ~12 новых тестов на Space-сценарии. Итого целевой range — 106–110 тестов. + +Критическая деталь: `AsyncClient.room_create` принимает `space=True` (булевый параметр, не `room_type="m.space"`) для создания Space. Добавление дочерней комнаты — через `room_put_state` на Space с event_type `m.space.child` и state_key = child room_id. Это проверено против установленной версии matrix-nio. + +**Primary recommendation:** Реализовать в трёх независимых задачах Codex: (1) invite flow — Space+rooms creation, (2) send_outgoing — убрать реакции, добавить pending-confirm store, (3) обновить тесты под новое поведение. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| matrix-nio | установлена (проверено: `space=True` параметр присутствует) | Matrix async клиент — room_create, room_put_state, room_invite, join | Единственный maintained async Python Matrix клиент | +| structlog | уже используется | Логирование | Уже в проекте | +| pytest-asyncio | уже используется | Async тесты | Уже в проекте | + +**Версию matrix-nio не нужно менять.** Установленная версия поддерживает `space=True` в `room_create` и `room_put_state` для state events. + +--- + +## Architecture Patterns + +### Паттерн 1: Создание Space + первой комнаты (invite flow) + +**Что:** При первом invite бот делает 5 последовательных API вызовов — создание Space, создание chat-комнаты, линковка child→Space, приглашение пользователя в обе, запись в store. + +**Verified API** (из installed matrix-nio): + +```python +# 1. Создать Space +space_resp = await client.room_create( + name=f"Lambda — {display_name}", + space=True, # <-- булевый флаг, не room_type + visibility="private", + is_direct=False, +) +# space_resp.room_id — строка + +# 2. Создать первую chat-комнату +chat_resp = await client.room_create( + name="Чат 1", + visibility="private", + is_direct=False, +) +# chat_resp.room_id — строка + +# 3. Добавить комнату в Space как child +# state_key = room_id дочерней комнаты +await client.room_put_state( + room_id=space_resp.room_id, + event_type="m.space.child", + content={ + "via": [homeserver_domain], # например "matrix.org" + }, + state_key=chat_resp.room_id, +) + +# 4. Пригласить пользователя в Space и в chat-комнату +await client.room_invite(space_resp.room_id, matrix_user_id) +await client.room_invite(chat_resp.room_id, matrix_user_id) + +# 5. Записать в store +await set_user_meta(store, matrix_user_id, { + "space_id": space_resp.room_id, + "next_chat_index": 2, # C1 уже занят +}) +await set_room_meta(store, chat_resp.room_id, { + "room_type": "chat", + "chat_id": "C1", + "display_name": "Чат 1", + "matrix_user_id": matrix_user_id, + "space_id": space_resp.room_id, +}) +``` + +**Важный gotcha:** Бот сам не вступает в Space (join). Он создаёт Space как владелец, поэтому уже является членом. `join` нужен только для входящей DM-комнаты (invite в существующую комнату). В новом flow: бот создаёт комнаты сам, поэтому `join` для Space и chat-комнаты не нужен. + +### Паттерн 2: Добавление новой комнаты (!new) + +```python +async def handle_new_chat(...): + user_meta = await get_user_meta(store, event.user_id) or {} + space_id = user_meta.get("space_id") + if not space_id: + # Пользователь не прошёл invite flow — не должно случиться, но guard нужен + return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: Space не найден.")] + + chat_id = await next_chat_id(store, event.user_id) + room_name = " ".join(event.args).strip() or f"Чат {chat_id}" + + resp = await client.room_create(name=room_name, visibility="private", is_direct=False) + room_id = resp.room_id + + homeserver = event.user_id.split(":")[1] # "@user:matrix.org" → "matrix.org" + await client.room_put_state( + room_id=space_id, + event_type="m.space.child", + content={"via": [homeserver]}, + state_key=room_id, + ) + await client.room_invite(room_id, event.user_id) + await set_room_meta(store, room_id, { + "room_type": "chat", + "chat_id": chat_id, + "display_name": room_name, + "matrix_user_id": event.user_id, + "space_id": space_id, + }) +``` + +### Паттерн 3: Archive (!archive) — убрать из Space + +```python +# Убрать child: поставить пустой content (или content без 'via') +# Matrix spec: отправить m.space.child с пустым {} или без 'via' удаляет связь +await client.room_put_state( + room_id=space_id, + event_type="m.space.child", + content={}, # пустой content = удалить child relationship + state_key=room_id, # room_id архивируемой комнаты +) +``` + +Confidence: MEDIUM — Matrix spec говорит что пустой content убирает child, но поведение Element может варьироваться. Альтернатива: оставить room_put_state с `{"via": []}` (пустой массив). + +### Паттерн 4: OutgoingUI → текст + !yes/!no (без реакций) + +**Что убрать:** +- `_button_action_to_reaction` в `bot.py` — удалить целиком +- Блок `for button in event.buttons: reaction = _button_action_to_reaction(...)` — удалить +- `ReactionEvent` callback (`on_reaction` + `client.add_event_callback`) — удалить +- `from_reaction` в converter — оставить (используется для skill-reactions), но skill-reaction инфраструктура тоже под вопросом (D-06 убирает реакции полностью) + +**Что добавить в `send_outgoing` для `OutgoingUI`:** +```python +if isinstance(event, OutgoingUI): + lines = [event.text, ""] + for button in event.buttons: + lines.append(f"• {button.label}") + lines += ["", "Ответьте !yes для подтверждения или !no для отмены."] + body = "\n".join(lines) + await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) + # Сохранить pending state per (user_id, room_id) + await set_pending_confirm(store, user_id=???, room_id=room_id, action_id=???) +``` + +**Проблема:** `send_outgoing` сейчас не знает `user_id` — только `room_id`. Для сохранения pending state нужен либо рефакторинг сигнатуры, либо хранение pending по `room_id` (без user_id — достаточно, т.к. room_id уникален для конкретного пользователя в Space модели). + +### Паттерн 5: Pending confirm state + +```python +# Новые helpers в adapter/matrix/store.py +PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" + +async def get_pending_confirm(store, room_id: str) -> dict | None: + return await store.get(f"{PENDING_CONFIRM_PREFIX}{room_id}") + +async def set_pending_confirm(store, room_id: str, meta: dict) -> None: + await store.set(f"{PENDING_CONFIRM_PREFIX}{room_id}", meta) + +async def clear_pending_confirm(store, room_id: str) -> None: + await store.delete(f"{PENDING_CONFIRM_PREFIX}{room_id}") +``` + +`!yes`/`!no` уже конвертируются в `IncomingCallback(action="confirm"/"cancel")` в `converter.py`. Нужно обновить `handle_confirm`/`handle_cancel` в `adapter/matrix/handlers/confirm.py` чтобы читать pending state и возвращать осмысленный ответ. + +### Паттерн 6: Hardcoded "C1" bug fix + +```python +# auth.py:27 — СЕЙЧАС (баг): +"chat_id": "C1" + +# ДОЛЖНО БЫТЬ: +chat_id = await next_chat_id(store, matrix_user_id) # возвращает "C1" для первого пользователя +``` + +`next_chat_id` уже существует в `store.py` и правильно инкрементирует per-user. Нужно просто использовать его в `handle_invite` вместо хардкода. + +### Рекомендуемая структура store после рефакторинга + +Текущие ключи в store: +- `matrix_room:{room_id}` → `{room_type, chat_id, display_name, matrix_user_id}` — **добавить `space_id`** +- `matrix_user:{user_id}` → `{next_chat_index, ...}` — **добавить `space_id`** +- `matrix_state:{room_id}` → `{state}` — оставить как есть +- `matrix_skills_msg:{room_id}` → `{event_id}` — оставить (или убрать если реакции полностью уходят) + +Новые ключи: +- `matrix_pending_confirm:{room_id}` → `{action_id, description, expires_at}` — для !yes/!no + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Space creation | Кастомный HTTP запрос к Matrix API | `AsyncClient.room_create(space=True)` | Встроено в matrix-nio, управляет session state | +| Adding child room to Space | Кастомный state event builder | `AsyncClient.room_put_state(room_id, "m.space.child", ...)` | Правильный Content-Type, auth headers автоматически | +| User invite | Прямой HTTP PUT | `AsyncClient.room_invite(room_id, user_id)` | Обрабатывает ошибки M_FORBIDDEN, already-joined | +| Error detection | Проверка статус-кодов | `isinstance(resp, RoomCreateError)` / `isinstance(resp, RoomPutStateError)` | matrix-nio возвращает типизированные error-объекты | + +--- + +## Common Pitfalls + +### Pitfall 1: `room_create(space=True)` vs `room_type="m.space"` + +**What goes wrong:** Передача `room_type="m.space"` как отдельный параметр — работает, но `space=True` — это удобный shortcut в matrix-nio, который внутри устанавливает тот же `room_type`. Оба варианта корректны, но `space=True` проще читается. + +**Проверено:** `room_create` signature в installed matrix-nio имеет `space: bool = False`. Нет отдельного `is_space` параметра. + +**How to avoid:** Использовать `space=True`, не `room_type="m.space"`. + +### Pitfall 2: `room_id` из RoomCreateResponse — не `getattr` + +**What goes wrong:** Текущий код в `handlers/chat.py:55`: `room_id = getattr(response, "room_id", None)`. Это работает для RoomCreateResponse, но молча возвращает None если пришёл RoomCreateError (у которого нет `room_id`). + +**How to avoid:** +```python +from nio.responses import RoomCreateError +resp = await client.room_create(...) +if isinstance(resp, RoomCreateError): + logger.error("room_create failed", status_code=resp.status_code) + return [OutgoingMessage(..., text="Не удалось создать комнату.")] +room_id = resp.room_id # прямой доступ, не getattr +``` + +### Pitfall 3: `m.space.child` — state_key это room_id дочерней комнаты, не пустая строка + +**What goes wrong:** `room_put_state` по умолчанию `state_key=""`. Для `m.space.child` state_key ДОЛЖЕН быть room_id дочерней комнаты — иначе Space создастся некорректно. + +**How to avoid:** Всегда передавать `state_key=child_room_id` явно. + +### Pitfall 4: Бот должен быть в Space чтобы добавлять children + +**What goes wrong:** Бот создаёт Space (становится владельцем), потом пытается сделать `room_put_state` на Space. Это работает т.к. создатель автоматически имеет power level 100. Но если бот потерял membership (kicked out), `room_put_state` вернёт `M_FORBIDDEN`. + +**How to avoid:** Логировать ошибку и сообщать пользователю. Не ретраить молча. + +### Pitfall 5: Дублирование invite flow (идемпотентность) + +**What goes wrong:** Текущий `handle_invite` проверяет `get_room_meta(store, room.room_id)` чтобы не запускать flow дважды. После рефакторинга на Space+rooms нужно проверять `get_user_meta(store, matrix_user_id)` — потому что invite может прийти повторно в разные комнаты Space, а Space создаётся один раз per user. + +**How to avoid:** Idempotency check переносится на уровень user_meta: `if user_meta.get("space_id"): return`. + +### Pitfall 6: `skills_message` реакции — остаток от старого UX + +**What goes wrong:** `adapter/matrix/reactions.py` и `build_skills_text` до сих пор рендерят "Реакции 1️⃣-9️⃣ переключают навыки." По D-06 реакции убраны полностью. `build_skills_text` нужно обновить чтобы убрать эту строку и заменить инструкцией `!skill on/off `. + +**How to avoid:** Обновить `build_skills_text` + тест `test_reactions.py::test_build_skills_text`. + +### Pitfall 7: `on_reaction` callback остаётся зарегистрированным + +**What goes wrong:** В `main()` есть `client.add_event_callback(bot.on_reaction, ReactionEvent)`. Если убрать реакции но оставить этот callback — matrix-nio будет продолжать обрабатывать реакции и вызывать `on_reaction`. Нужно удалить и callback-регистрацию, и импорт `ReactionEvent`. + +--- + +## Gaps between Current Implementation and Target + +| File | Current State | Target State | Action | +|------|--------------|-------------|--------| +| `adapter/matrix/handlers/auth.py` | DM join + hardcoded C1 | Space creation + C1 from next_chat_id | Переписать `handle_invite` | +| `adapter/matrix/handlers/chat.py` | room_create без Space | room_create + room_put_state в Space | Обновить `make_handle_new_chat` | +| `adapter/matrix/bot.py` | `on_reaction` + `_button_action_to_reaction` | Без реакций, pending-state для !yes/!no | Убрать reaction code; обновить `send_outgoing` | +| `adapter/matrix/store.py` | Нет `space_id`, нет pending_confirm | `space_id` в room_meta + user_meta; `pending_confirm` helpers | Добавить поля и helpers | +| `adapter/matrix/reactions.py` | `build_skills_text` упоминает реакции | `build_skills_text` без реакций, с `!skill on/off` | Обновить текст | +| `adapter/matrix/handlers/confirm.py` | Заглушка без state | Читает pending_confirm, даёт реальный ответ | Обновить handlers | +| `adapter/matrix/handlers/settings.py` | `handle_settings` — список команд | `handle_settings` — read-only дашборд (D-12) | Обновить до дашборда со статусом | +| `adapter/matrix/converter.py` | `from_reaction` используется для skill toggle | Skill toggle через реакции убирается | `from_reaction` можно оставить или удалить | + +--- + +## Code Examples + +### Создание Space + child room (verified API) + +```python +# Source: matrix-nio installed version — inspect.signature(AsyncClient.room_create) +from nio.responses import RoomCreateError, RoomPutStateError + +async def create_user_space(client, display_name: str, matrix_user_id: str, store): + homeserver = matrix_user_id.split(":")[-1] # "@user:matrix.org" → "matrix.org" + + # Step 1: Create Space + space_resp = await client.room_create( + name=f"Lambda — {display_name}", + space=True, + visibility="private", + ) + if isinstance(space_resp, RoomCreateError): + return None, None + space_id = space_resp.room_id + + # Step 2: Create first chat room + chat_resp = await client.room_create( + name="Чат 1", + visibility="private", + is_direct=False, + ) + if isinstance(chat_resp, RoomCreateError): + return space_id, None + chat_room_id = chat_resp.room_id + + # Step 3: Link child room into Space (state_key = child's room_id) + await client.room_put_state( + room_id=space_id, + event_type="m.space.child", + content={"via": [homeserver]}, + state_key=chat_room_id, + ) + + # Step 4: Invite user to Space and to chat room + await client.room_invite(space_id, matrix_user_id) + await client.room_invite(chat_room_id, matrix_user_id) + + return space_id, chat_room_id +``` + +### send_outgoing для OutgoingUI (без реакций) + +```python +if isinstance(event, OutgoingUI): + lines = [event.text] + if event.buttons: + lines.append("") + for btn in event.buttons: + lines.append(f"• {btn.label}") + lines.append("") + lines.append("Ответьте !yes для подтверждения или !no для отмены.") + body = "\n".join(lines) + await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) +``` + +### Проверка ошибок matrix-nio + +```python +from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError + +resp = await client.room_create(...) +if isinstance(resp, RoomCreateError): + logger.error("room_create failed", status_code=resp.status_code) + # resp не имеет room_id — безопасный ранний возврат + return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")] +room_id = resp.room_id # str, гарантированно присутствует +``` + +--- + +## Validation Architecture + +nyquist_validation = true в config.json — раздел обязателен. + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest + pytest-asyncio | +| Config file | pytest.ini или pyproject.toml (проверить наличие) | +| Quick run command | `pytest tests/adapter/matrix/ -q` | +| Full suite command | `pytest tests/ -q` | +| Current count | 97 passed | + +### Существующие тесты Matrix, требующие обновления + +Эти тесты написаны под DM/reaction-based поведение и сломаются после рефакторинга: + +| Test | Текущее поведение | После рефакторинга | Действие | +|------|------------------|-------------------|---------| +| `test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome` | Проверяет `chat_id == "C1"` через hardcode, join DM | Должен проверять Space creation + chat room creation | Переписать | +| `test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available` | Проверяет `room_create` без Space | Должен проверять `room_create` + `room_put_state` | Обновить mock + assertions | +| `test_reactions.py::test_build_skills_text` | Ожидает "Реакции 1️⃣-9️⃣" в тексте | После удаления реакций эта строка исчезнет | Обновить assertion | +| `test_reactions.py::test_build_confirmation_text` | Проверяет `CONFIRM_REACTION` + "подтвердить" | Если `build_confirmation_text` обновится под D-07 | Обновить | + +### Новые тесты, необходимые для покрытия Space+rooms + +| ID | Behavior | Test Type | File | Command | +|----|----------|-----------|------|---------| +| MAT-01 | handle_invite создаёт Space + Чат 1, сохраняет space_id в user_meta | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` | +| MAT-02 | handle_invite идемпотентен: повторный вызов не создаёт второй Space | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` | +| MAT-03 | handle_invite использует next_chat_id, не хардкод "C1" | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` | +| MAT-04 | make_handle_new_chat вызывает room_put_state с space_id из user_meta | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | +| MAT-05 | make_handle_new_chat без space_id возвращает error message | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | +| MAT-06 | send_outgoing для OutgoingUI рендерит текст + "!yes / !no", без реакций | unit | `tests/adapter/matrix/test_send_outgoing.py` | `pytest tests/adapter/matrix/test_send_outgoing.py -x` | +| MAT-07 | send_outgoing для OutgoingUI НЕ отправляет m.reaction event | unit | `tests/adapter/matrix/test_send_outgoing.py` | `pytest tests/adapter/matrix/test_send_outgoing.py -x` | +| MAT-08 | get/set/clear_pending_confirm roundtrip в store | unit | `tests/adapter/matrix/test_store.py` (extend) | `pytest tests/adapter/matrix/test_store.py -x` | +| MAT-09 | handle_confirm читает pending_confirm и возвращает описание действия | unit | `tests/adapter/matrix/test_confirm.py` | `pytest tests/adapter/matrix/test_confirm.py -x` | +| MAT-10 | handle_archive вызывает room_put_state с пустым content | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | +| MAT-11 | !settings возвращает дашборд со статусом (не список команд) | unit | `tests/adapter/matrix/test_dispatcher.py` (extend) | `pytest tests/adapter/matrix/test_dispatcher.py -x` | +| MAT-12 | RoomCreateError обрабатывается корректно (нет crash, есть user message) | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | + +### Wave 0 Gaps (новые файлы) + +- [ ] `tests/adapter/matrix/test_invite_space.py` — покрывает MAT-01, MAT-02, MAT-03 +- [ ] `tests/adapter/matrix/test_chat_space.py` — покрывает MAT-04, MAT-05, MAT-10, MAT-12 +- [ ] `tests/adapter/matrix/test_send_outgoing.py` — покрывает MAT-06, MAT-07 +- [ ] `tests/adapter/matrix/test_confirm.py` — покрывает MAT-09 + +### Sampling Rate + +- **Per task commit:** `pytest tests/adapter/matrix/ -q` +- **Per wave merge:** `pytest tests/ -q` +- **Phase gate:** All 97+ tests green (целевой диапазон 106–110 после добавления новых) + +### Численный ориентир для "96+ зелёных" + +- Сейчас: 97 тестов, все зелёные +- После рефакторинга без добавления тестов: 4 теста сломаются (3 dispatcher + 1 reactions) → ~93 зелёных +- После обновления сломанных: 97 зелёных +- После добавления 12 новых: ~109 зелёных +- **Итого: требование "96+" выполнено с запасом** + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| matrix-nio | All Matrix API calls | ✓ | установлена, space=True присутствует | — | +| pytest + pytest-asyncio | Test suite | ✓ | работает (97 passed) | — | +| SQLite | SQLiteStore | ✓ | встроен в Python | — | +| Matrix homeserver | Manual QA только | не проверялось | — | Без homeserver — только unit тесты | + +**Missing dependencies with no fallback:** Нет (homeserver нужен только для ручного QA, не для автотестов). + +--- + +## Project Constraints (from CLAUDE.md) + +| Directive | Impact on Phase | +|-----------|----------------| +| `core/protocol.py` — типы не менять | `IncomingCommand`, `OutgoingUI`, `UIButton` используем as-is | +| Все вызовы платформы через `platform/interface.py` | MockPlatformClient остаётся, SDK не трогать | +| Хотфиксы < 20 строк → Claude Code напрямую | Небольшие правки реакций-в-текст могут идти напрямую | +| Реализацию делает Codex | Три задачи — три параллельных Codex запуска | +| Blueprint перед реализацией | Плану нужны blueprint-документы для каждой задачи | +| Порядок зависимостей: core/ → platform/ → adapters/ | Все изменения только в adapter/matrix/, core/ не трогаем | + +--- + +## Open Questions + +1. **Стоит ли полностью убирать `from_reaction` и `reactions.py`?** + - D-06 говорит "убрать реакции полностью" + - `reactions.py` содержит `build_confirmation_text` и `build_skills_text` — они нужны после рефакторинга + - Рекомендация: оставить `reactions.py`, удалить `CONFIRM_REACTION`/`CANCEL_REACTION`/`add_reaction`/`remove_reaction`, переименовать в `formatting.py` — но это необязательно для Phase 1. + +2. **Нужен ли `m.space.parent` event в дочерних комнатах?** + - Matrix spec позволяет устанавливать `m.space.parent` в дочерней комнате, чтобы Element показывал ссылку "назад к Space" + - Не является обязательным — `m.space.child` в Space достаточно для включения комнаты в Space + - Рекомендация: не добавлять в Phase 1, отложить если понадобится. + +3. **`via` в `m.space.child` — один сервер или несколько?** + - Для single-homeserver деплоя: `["homeserver_domain"]` достаточно + - Для федерации: нужны несколько серверов + - Рекомендация: парсить из `matrix_user_id.split(":")[-1]` — достаточно для текущего использования. + +--- + +## Sources + +### Primary (HIGH confidence) +- matrix-nio installed package — `AsyncClient.room_create`, `room_put_state`, `room_invite`, `join` — сигнатуры и docstrings проверены через `inspect.signature` и `help()` +- `nio.responses.RoomCreateResponse`, `RoomCreateError`, `RoomPutStateResponse`, `RoomPutStateError` — поля проверены через `inspect.getsource` +- Весь codebase прочитан напрямую + +### Secondary (MEDIUM confidence) +- Matrix Spec v1.x — `m.space.child` event format (content `{"via": [...]}`, state_key = child room_id) — стандартное поведение, описано в Matrix spec + +--- + +## Metadata + +**Confidence breakdown:** +- matrix-nio API: HIGH — проверено против installed package через Python introspection +- Space creation pattern: HIGH — `space=True` параметр подтверждён в room_create signature +- `m.space.child` content format: MEDIUM — стандарт Matrix spec, не проверен против конкретного homeserver +- Archive via empty content: MEDIUM — Matrix spec behaviour, может зависеть от homeserver version +- Тест-план: HIGH — основан на прямом анализе существующих тестов + +**Research date:** 2026-04-02 +**Valid until:** 2026-05-02 (matrix-nio обновляется редко, Space API стабилен с Matrix v1.2)