diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..2d88441
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,22 @@
+.git
+.gitignore
+.DS_Store
+__pycache__/
+.pytest_cache/
+.ruff_cache/
+.venv/
+.worktrees/
+external/
+.planning/
+docs/superpowers/
+tests/
+
+# Local runtime state must not be baked into the image.
+lambda_matrix.db
+matrix_store/
+lambda_bot.db
+config/matrix-agents.yaml
+
+# Local environment and editor state
+.env
+.idea/
diff --git a/.env.example b/.env.example
index ef8e7ce..cc5f2e0 100644
--- a/.env.example
+++ b/.env.example
@@ -1,14 +1,32 @@
-# Telegram
-TELEGRAM_BOT_TOKEN=your_bot_token_here
-
-# Matrix
-MATRIX_HOMESERVER=https://matrix.org
-MATRIX_USER_ID=@bot:matrix.org
+# Matrix bot credentials
+MATRIX_HOMESERVER=https://matrix.example.org
+MATRIX_USER_ID=@lambda-bot:example.org
+# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN
MATRIX_PASSWORD=your_password_here
+# MATRIX_ACCESS_TOKEN=your_access_token_here
-# Lambda Platform
-LAMBDA_PLATFORM_URL=http://localhost:8000
-LAMBDA_SERVICE_TOKEN=your_service_token_here
+# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only)
+MATRIX_PLATFORM_BACKEND=real
-# Режим работы: "mock" или "production"
-PLATFORM_MODE=mock
+# Published surface image used by docker-compose.prod.yml.
+# Must point to a Docker Hub/registry namespace where you have push/pull access.
+SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
+
+# platform/agent_api ref used when building a surface image
+LAMBDA_AGENT_API_REF=master
+
+# Path to agent registry inside the container (mounted via ./config:/app/config:ro)
+MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml
+
+# HTTP URL of the platform-agent endpoint
+# Production: external agent managed by the platform
+# Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml
+AGENT_BASE_URL=http://your-agent-host:8000
+
+# Shared volume path inside the bot container (default: /agents).
+# For multi-agent production, each agent gets a subdirectory such as /agents/0.
+SURFACES_WORKSPACE_DIR=/agents
+
+# Docker volume names (created automatically on first run)
+SURFACES_SHARED_VOLUME=surfaces-agents
+SURFACES_BOT_STATE_VOLUME=surfaces-bot-state
diff --git a/.gitignore b/.gitignore
index e8e4f81..6930373 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ build/
# Git worktrees (не трекаем в репо)
.worktrees/
+external/
# IDE
.idea/
diff --git a/.planning/.continue-here.md b/.planning/.continue-here.md
new file mode 100644
index 0000000..f27ae84
--- /dev/null
+++ b/.planning/.continue-here.md
@@ -0,0 +1,72 @@
+---
+context: pre-planning
+phase: 05-deployment
+task: 0
+total_tasks: 0
+status: ready-to-plan
+last_updated: 2026-04-27T18:44:51.832Z
+---
+
+
+Phase 04 полностью завершена и закоммичена на ветке `feat/matrix-direct-agent-prototype` (135 тестов зелёные). Этот сеанс был посвящён архитектуре деплоя — изучили платформенные репозитории и обсудили топологию с командой платформы. Вся информация о деплое зафиксирована в `docs/deploy-architecture.md`. Phase 05 не спланирована, следующий шаг — `/gsd-plan-phase`.
+
+
+
+
+- Изучены актуальные версии platform-agent, platform-agent_api, platform-master
+- Уточнена топология деплоя с платформой (схема с reverse proxy и shared volume)
+- Созданы `docs/deploy-architecture.md` — полное summary архитектуры деплоя
+
+
+
+
+- Смержить `feat/matrix-direct-agent-prototype` → `main`
+- Спланировать Phase 05 (деплой)
+- Выполнить Phase 05:
+ - Обновить `config/matrix-agents.yaml` (добавить `base_url`, `workspace_path`, `user_agents`)
+ - Обновить `sdk/real.py` (AgentApi конструктор, file transfer)
+ - Обработка `MsgEventSendFile` в Matrix адаптере (скачать файл из volume, отправить пользователю)
+ - Обработка входящих файлов от Matrix пользователей (сохранить в workspace, передать в attachments)
+ - Написать `docker-compose.yml` для деплоя
+
+
+
+
+- **Топология**: один инстанс Matrix-бота, один агент-контейнер на пользователя, reverse proxy на `lambda.coredump.ru:7000` роутит по пути `/agent_N/`
+- **Файлы**: через shared volume `/agents/`. Surface пишет файл в `/agents/{N}/`, передаёт относительный путь в `attachments=["file.txt"]`. При `MsgEventSendFile(path)` — читает файл из `/agents/{N}/{path}` и шлёт в Matrix.
+- **Agent API**: используем master (`attachments` и `MsgEventSendFile` есть). Ветку `#9-clientside-tool-call` игнорируем — она в разработке и убирает нужные фичи.
+- **Конфиг**: два словаря — `user_id → agent_id` и `agent_id → {base_url, workspace_path}`
+- **Master**: не используем для MVP. Статический конфиг. При готовности Master — мигрируем.
+- **chat_id**: пока `chat_id=0` (один контекст на пользователя)
+
+
+
+
+- **AGENT_ID + COMPOSIO_API_KEY**: Composio смержен в main platform-agent, теперь обязателен. Значения нужны от Азамата перед деплоем.
+- **agent_api #9**: убирает `attachments` и `MsgEventSendFile` — если смержат до деплоя, сломает наш file transfer. Нужно уточнить сроки.
+
+
+## Required Reading (in order)
+
+1. `docs/deploy-architecture.md` — полная архитектура деплоя, топология, API, файловый обмен, конфиг
+2. `adapter/matrix/routed_platform.py` — текущий RoutedPlatformClient
+3. `sdk/real.py` — текущий AgentApi wrapper
+4. `config/matrix-agents.yaml` и `config/matrix-agents.example.yaml` — текущий формат конфига (нужно расширить)
+
+## Infrastructure State
+
+- Ветка: `feat/matrix-direct-agent-prototype` — готова к merge, 135 тестов зелёные
+- `config/matrix-agents.yaml` — незакоммичен (live config, добавить в `.gitignore`)
+- `docs/deploy-architecture.md` — незакоммичен (новый файл этого сеанса)
+- platform-agent main: Composio уже смержен (требует `AGENT_ID`, `COMPOSIO_API_KEY` в env)
+
+
+Архитектура деплоя полностью прояснена. Нет неизвестных блокеров (кроме env-переменных от платформы). Phase 05 — чисто инженерная задача: обновить конфиг, sdk, Matrix адаптер, написать compose. Всё что нужно знать — в docs/deploy-architecture.md.
+
+
+
+1. /clear
+2. /gsd-resume-work — прочитает этот файл и предложит план Phase 05
+3. Прочитать docs/deploy-architecture.md
+4. /gsd-plan-phase 05
+
diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json
deleted file mode 100644
index 75fcb6b..0000000
--- a/.planning/HANDOFF.json
+++ /dev/null
@@ -1,87 +0,0 @@
-{
- "version": "1.0",
- "timestamp": "2026-04-04T10:13:58.720Z",
- "phase": "01.1",
- "phase_name": "matrix-restart-reconciliation-and-dev-reset-workflow",
- "phase_dir": ".planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow",
- "plan": 3,
- "task": 1,
- "total_tasks": 2,
- "status": "paused",
- "completed_tasks": [],
- "remaining_tasks": [
- {
- "id": 1,
- "name": "Add a dev-only Matrix reset CLI with explicit modes",
- "status": "not_started"
- },
- {
- "id": 2,
- "name": "Replace the README reset ritual with the new restart and reset workflow",
- "status": "not_started"
- }
- ],
- "blockers": [
- {
- "description": "Phase 02 SDK integration remains blocked because platform control-plane contract is not stable yet; current platform repos only clearly expose the direct agent WebSocket layer.",
- "type": "external",
- "workaround": "Keep the current consumer-facing bot flows and mock-backed facade for now; use Matrix as the internal testing surface and revisit integration once master user/chat/session access is clarified."
- }
- ],
- "human_actions_pending": [
- {
- "action": "Confirm with the platform team the minimal control-plane contract for user/chat/session access and whether settings/attachments will exist in master.",
- "context": "Current evidence shows agent_api is usable, but master is not yet a stable consumer-facing API.",
- "blocking": true
- }
- ],
- "decisions": [
- {
- "decision": "Do not start a full rewrite of the consumer-facing bot integration yet.",
- "rationale": "Platform direction is visible, but too many pieces outside the direct agent WebSocket protocol are still undefined or inconsistent.",
- "phase": "02"
- },
- {
- "decision": "Treat sdk/mock.py as a temporary local integration facade rather than a near-drop-in replacement for the real platform.",
- "rationale": "The current mock assumes a unified platform API, while the real platform is split between control plane and direct agent session.",
- "phase": "02"
- },
- {
- "decision": "Use Matrix as the internal testing surface while waiting for the platform contract to stabilize.",
- "rationale": "This preserves product iteration without coupling the bot too early to a moving platform backend.",
- "phase": "02"
- }
- ],
- "uncommitted_files": [
- ".planning/config.json",
- "adapter/matrix/bot.py",
- "adapter/matrix/handlers/__init__.py",
- "adapter/matrix/handlers/auth.py",
- "adapter/matrix/handlers/chat.py",
- "adapter/matrix/handlers/settings.py",
- "adapter/telegram/bot.py",
- "sdk/mock.py",
- "tests/adapter/matrix/test_chat_space.py",
- "tests/adapter/matrix/test_dispatcher.py",
- "tests/adapter/matrix/test_invite_space.py",
- "tests/platform/test_mock.py",
- ".planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md",
- ".planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md",
- ".planning/phases/01-matrix-qa-polish/01-05-PLAN.md",
- ".planning/phases/01-matrix-qa-polish/01-06-PLAN.md",
- ".planning/phases/01-matrix-qa-polish/01-VERIFICATION.md",
- ".planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.gitkeep",
- "bot-examples/",
- "docs/reports/2026-04-01-surfaces-progress-report.md",
- "docs/superpowers/plans/2026-03-31-matrix-adapter.md",
- "docs/workflow-backup-2026-04-01.md",
- "forum_topics_research.md",
- "image copy 2.png",
- "image copy.png",
- "image.png",
- "lambda_bot.db",
- "lambda_matrix.db"
- ],
- "next_action": "When resuming, either execute Phase 01.1 Plan 03 Task 1 (Matrix reset CLI) or continue the platform-integration design by defining a split MasterClient/AgentSession boundary without changing consumer adapters yet.",
- "context_notes": "This session was research-heavy rather than implementation-heavy. The key conclusion is that the real platform currently exposes a direct agent WebSocket SDK plus an unfinished master control plane; our mock models a richer unified platform than what exists today. That means future work should isolate the integration boundary, not rush a full rewrite."
-}
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
index a8043bd..9c859f8 100644
--- a/.planning/PROJECT.md
+++ b/.planning/PROJECT.md
@@ -14,7 +14,7 @@ Telegram и Matrix боты для взаимодействия пользова
- ✓ core/ — унифицированный протокол событий, EventDispatcher, StateStore, ChatManager, AuthManager, SettingsManager — existing
- ✓ adapter/telegram/ — forum-first адаптер (Threaded Mode), `/start`, `/new`, `/archive`, `/rename`, `/settings`, стриминг ответов — existing, QA passed
-- ✓ adapter/matrix/ — DM-first адаптер, invite flow, `!new`, `!skills`, `!soul`, `!safety`, room-per-chat — existing
+- ✓ adapter/matrix/ — Space+rooms адаптер, invite flow, `!new`, `!archive`, `!rename`, `!settings`, room-per-chat — existing
- ✓ sdk/mock.py — MockPlatformClient: `stream_message`, `get_or_create_user`, `get_settings`, `update_settings` — existing
### Active
@@ -50,7 +50,7 @@ Telegram и Matrix боты для взаимодействия пользова
| Forum-first (Threaded Mode) для Telegram | Bot API 9.3 позволяет личный чат как форум — чище, без суперпруппы | ✓ Good |
| (user_id, thread_id) как PK в chats | Изоляция контекстов по топику | ✓ Good |
| MockPlatformClient через sdk/interface.py | Не ждать SDK, разрабатывать независимо | ✓ Good |
-| DM-first для Matrix (не Space-first) | Space lifecycle слишком сложен для первого этапа | ✓ Good |
+| Space+rooms для Matrix | Room-based UX и явные чаты важнее DM-first упрощений | ✓ Good |
| Отказ от E2EE в Matrix | python-olm не собирается на macOS/ARM | — Pending |
## Evolution
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 175285d..4e8799b 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -50,6 +50,41 @@ Plans:
- `stream_message` работает с реальным стримингом
- Интеграционные тесты с реальным SDK (или staging)
+### Phase 4: Matrix MVP: shared agent context and context management commands
+
+**Goal:** Привести Matrix-бот к рабочему состоянию для MVP-деплоя: заменить AgentSessionClient на AgentApi, добавить !save/!load/!reset/!context команды управления контекстом агента, упаковать в Docker.
+**Requirements**: Replace AgentSessionClient with AgentApi; Wire AgentApi lifecycle; Implement !save, !load, !reset, !context commands; Dockerfile + docker-compose
+**Depends on:** Phase 1 (Matrix adapter complete)
+**Plans:** 3 plans
+
+Plans:
+- [x] 04-01-PLAN.md — Replace AgentSessionClient with AgentApi; update sdk/real.py, bot.py, broken tests
+- [x] 04-02-PLAN.md — !save, !load, !reset, !context handlers; PrototypeStateStore extensions; numeric interception
+- [x] 04-03-PLAN.md — Dockerfile + docker-compose.yml + .env.example update
+
+---
+
+### Phase 05: MVP Deployment
+
+**Goal:** Подготовить Matrix-бот к реальному деплою на lambda.coredump.ru без потери Space+rooms UX: закрепить per-room `platform_chat_id`, реальный `!clear`, reconciliation, file transfer через shared volume и разделение prod/fullstack compose.
+
+**Depends on:** Phase 4
+
+**Plans:** 4/4 plans complete
+
+Plans:
+- [x] 05-01-PLAN.md — Startup reconciliation from authoritative Matrix Space topology before live sync
+- [x] 05-02-PLAN.md — Room-local `platform_chat_id` routing and real `!clear` semantics
+- [x] 05-03-PLAN.md — Shared-volume attachment path hardening for `/agents` deployment
+- [x] 05-04-PLAN.md — Split bot-only prod compose from internal fullstack compose and update docs
+
+**Deliverables:**
+- Space+rooms onboarding remains the primary Matrix UX
+- Per-room `platform_chat_id` provides true context isolation and `!clear`
+- Reconciliation restores room metadata and routing after restart
+- File transfer uses shared `/agents/` volume with room-safe behavior
+- `docker-compose.prod.yml` is bot-only handoff; `docker-compose.fullstack.yml` is for internal E2E testing
+
---
### Phase 3: Production Hardening
diff --git a/.planning/STATE.md b/.planning/STATE.md
index c573685..eb05f42 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -2,13 +2,13 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: — Production-ready surfaces
-status: Phase 01 Complete
-last_updated: "2026-04-03T09:35:39Z"
+status: Phase 05 Paused
+last_updated: "2026-04-29T08:49:04Z"
progress:
- total_phases: 3
- completed_phases: 1
- total_plans: 6
- completed_plans: 6
+ total_phases: 6
+ completed_phases: 3
+ total_plans: 16
+ completed_plans: 13
---
# State
@@ -18,13 +18,41 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-02)
**Core value:** Пользователь ведёт диалог с Lambda через любой мессенджер без изменения ядра
-**Current focus:** Phase 02 — SDK Integration (blocked on Lambda platform SDK readiness)
+**Current focus:** Phase 05 paused — latest file-contract change needs a new image build before platform redeploy
## Current Phase
-**Phase 2** of 3: SDK Integration
+**Phase 05** paused: MVP deployment hardening is in place, but the latest attachment workspace-root change is not yet published
-Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is available.
+Deployment handoff follow-up is external. The last published image predates the latest file-handling change; the next step is to rebuild and publish a fresh image, then ask the platform to redeploy Matrix with the shared `/agents` volumes and `config/matrix-agents.yaml`.
+
+Plan `05-01` is complete. Matrix startup now reconciles managed Space rooms from synced topology before live traffic, restoring local metadata and deterministic legacy `platform_chat_id` bindings on restart.
+
+- `a75b26a` — failing restart reconciliation regressions for recovery, idempotence, startup ordering, and legacy backfill
+- `8a80d00` — startup reconciliation module and pre-sync wiring in the Matrix runtime
+
+Verified with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v`.
+
+Plan `05-02` is complete. Matrix room-local context commands now rely on repaired per-room `platform_chat_id` bindings, and `!clear` rotates only the active room's upstream context when prototype room state is available.
+
+- `ae37476` — failing regressions for clear registration, room-local rotation, and strict routed-platform metadata requirements
+- `85e2fda` — room-local clear semantics, compatibility alias wiring, and strict context resolution without shared chat fallbacks
+
+Verified with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v`.
+
+Plan `05-03` is complete. Shared-volume attachment handling now preserves relative agent paths while tolerating both `/workspace` and `/agents` absolute prefixes during normalization and Matrix file rendering.
+
+- `7a12a71` — failing regressions for shared-volume path normalization and room-safe attachment handling
+- `5eddf16` — `/agents` deployment path hardening for Matrix files and routed platform attachments
+
+Verified with `uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v`.
+
+Plan `05-04` is complete. Production handoff now uses `docker-compose.prod.yml` for a bot-only runtime, while internal end-to-end verification uses `docker-compose.fullstack.yml` with shared `/agents` volume guidance and health-gated startup.
+
+- `df6d8bf` — split prod and full-stack compose artifacts with the shared `/agents` contract
+- `22a3a2b` — operator and deployment docs aligned to the split compose artifacts
+
+Verified with `docker compose -f docker-compose.prod.yml config`, `docker compose -f docker-compose.fullstack.yml config`, and docs grep checks for `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and `/agents`.
## Decisions
@@ -42,16 +70,38 @@ Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is av
- [Phase 01]: Removed Matrix reaction conversion entirely and kept command callbacks limited to !yes/!no.
- [Phase 01]: Kept !settings as a pure snapshot surface while preserving mutable subcommands outside the dashboard.
- [Phase 01]: Seeded invite and dispatcher tests with explicit next_chat_index and room ids instead of treating C1 as Matrix transport identity.
+- [Phase 04]: Replaced AgentSessionClient with AgentApiWrapper and persistent agent connection lifecycle in Matrix runtime.
+- [Phase 04]: Added !save, !load, !reset, and !context commands with pending-state interception and local prototype session metadata.
+- [Phase 04]: Added Matrix-only Docker packaging for MVP deployment; platform services remain external to this compose setup.
+- [Phase 04]: Replaced the Matrix prod path again with direct upstream `AgentApi` per request; removed the local runtime wrapper from the prod flow.
+- [Phase 04]: Adopted `AGENT_BASE_URL` as the primary runtime contract and kept `AGENT_WS_URL` only as backward-compatible env fallback.
+- [Phase 04 follow-up]: Kept shared PlatformClient unchanged; introduced Matrix-specific RoutedPlatformClient to avoid breaking Telegram adapter.
+- [Phase 04 follow-up]: agent_routing_enabled flag on MatrixRuntime activates stale-room check only in real multi-agent mode (RoutedPlatformClient).
+- [Phase 04 follow-up]: !new binds agent_id at room creation time using selected_agent_id from user metadata.
+- [Phase 04 follow-up]: platform_chat_seq (PLATFORM_CHAT_SEQ_KEY) is stored in SQLiteStore and survives restart — confirmed by test.
+- [Phase 05 reset]: Discard the single-chat / DM-first deployment direction. Replan around Space+rooms, per-room `platform_chat_id`, real `!clear`, reconciliation, and split prod/fullstack compose artifacts.
+- [Phase 05]: Keep adapter/matrix/files.py as the sole path builder; sdk/real.py only normalizes shared-volume attachment references.
+- [Phase 05]: Normalize /workspace and /agents absolute file paths back to relative workspace_path values before agent transport and Matrix file rendering.
+- [Phase 05]: Treat synced Matrix topology as authoritative for startup recovery; keep SQLite rebuildable.
+- [Phase 05]: Backfill missing platform_chat_id values during startup reconciliation before routed handling begins.
+- [Phase 05]: Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias.
+- [Phase 05]: Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids.
+- [Phase 05]: Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification.
+- [Phase 05]: Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same named volume.
## Blockers
- Lambda platform SDK не готов — Phase 2 заблокирована до готовности платформы
+- Full production verification depends on the platform team's real multi-agent orchestration, production Matrix credentials, `config/matrix-agents.yaml`, and shared `/agents/N` volume mounts.
## Accumulated Context
### Roadmap Evolution
- Phase 01.1 inserted after Phase 01: Matrix restart reconciliation and dev reset workflow (URGENT)
+- Phase 4 added: Matrix MVP: shared agent context and context management command
+- Phase 04 follow-up added inline: multi-agent routing (RoutedPlatformClient, !agent, stale room blocking, restart persistence)
+- Phase 05 reset on 2026-04-28: erroneous single-chat deployment artifacts were removed before fresh planning.
## Performance Metrics
@@ -62,9 +112,18 @@ Phase 1 is complete. Phase 2 remains blocked until the Lambda platform SDK is av
| 01 | 03 | 3 min | 2 | 5 | 2026-04-02T19:57:34Z |
| 01 | 04 | 3 min | 2 | 7 | 2026-04-02T20:03:38Z |
| 01 | 05 | 2 min | 2 | 7 | 2026-04-03T09:28:47Z |
-| 01 | 06 | 4 min | 2 | 7 | 2026-04-03T09:35:39Z |
+| 01 | 06 | 4 min | 2 | 7 | 2026-04-03T09:35:47Z |
+| 04 | 01 | 1 session | 1 wave | 8 | 2026-04-17 |
+| 04 | 02 | 1 session | 2 commits + summary | 8 | 2026-04-17 |
+| 04 | 03 | 1 session | 1 commit + summary | 4 | 2026-04-17 |
+| 04 | follow-up | 1 session | 5 tasks | 10+ | 2026-04-24 |
+| 05 | 03 | 3 min | 2 | 3 | 2026-04-27T22:06:43Z |
+| 05 | 01 | 8 min | 2 | 4 | 2026-04-27T22:09:28Z |
+| 05 | 02 | 16 min | 2 | 4 | 2026-04-27T22:15:58Z |
+| 05 | 04 | 3 min | 2 | 5 | 2026-04-27T22:17:10Z |
## Session
-- Last session: 2026-04-03T09:35:39Z
-- Stopped at: Completed 01-06-PLAN.md
+- Last session: 2026-04-29T08:49:04Z
+- Stopped at: Handoff updated after attachment workspace-root change; waiting for image rebuild and platform redeploy
+- Resume file: .planning/phases/05-mvp-deployment/.continue-here.md
diff --git a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md
index 218d478..6de8f62 100644
--- a/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md
+++ b/.planning/phases/01.1-matrix-restart-reconciliation-and-dev-reset-workflow/.continue-here.md
@@ -3,46 +3,61 @@ phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
task: 1
total_tasks: 2
status: paused
-last_updated: 2026-04-04T10:13:58.720Z
+last_updated: 2026-04-07T21:29:48.982Z
---
-Formally, the most recently active GSD artifact is `01.1-03-PLAN.md`, which has not been executed yet. In parallel, an out-of-band research pass compared the local mock SDK against platform repos and concluded that Phase 02 SDK integration is still blocked on an unstable control-plane contract.
+Formally, the most recently active execution artifact inside the roadmap is still `01.1-03-PLAN.md`, which has not been implemented yet. In parallel, the platform-integration track has moved forward: the direct-agent Matrix prototype design is now approved, the implementation plan is written, and the next useful session should evaluate that spec/plan pair against the live platform repos before starting execution.
-- Session research: inspected local `sdk/interface.py`, `sdk/mock.py`, core message/settings usage, and platform repos `agent_api`, `agent`, `master`, `docs`.
-- Established that the real platform currently provides a direct WebSocket `agent_api` for talking to the agent, while `master` is still mostly a control-plane skeleton rather than a stable consumer-facing API.
-- Confirmed that the current local mock assumes a richer unified platform API than what is actually implemented today.
-- Concluded that consumer adapters should not be deeply rewritten yet; Matrix remains the right internal testing surface for now.
+- Re-analysed live platform repos on 2026-04-07 by cloning `platform/agent`, `platform/agent_api`, `platform/master`, and `platform/docs`.
+- Confirmed `master` is still only a thin HTTP skeleton with `/health` and `/users/{user_id}`, not a chat/session/settings backend for surfaces.
+- Confirmed `agent` exposes a working `/agent_ws/` WebSocket and `agent_api` provides enough protocol/client code to stream model output.
+- Identified the real technical gap for a prototype: `agent` currently uses a singleton service with a fixed `thread_id="default"`, so all conversations would share memory unless that is parameterized.
+- Derived and got approval for the prototype path: keep Matrix adapter logic largely intact, add `sdk/agent_session.py`, `sdk/prototype_state.py`, and `sdk/real.py`, keep settings local, and use the direct `agent` WebSocket for real messaging.
+- Resolved the repo-placement question: the prototype stays in this repo on its own branch, not in a separate prototype repo.
+- Resolved the platform-change minimization question: prefer patching only `platform/agent`, not `platform/agent_api`, and use a tiny local WebSocket client in this repo.
+- Wrote and committed the approved design spec: `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`.
+- Wrote the implementation plan: `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
- Task 1: Implement `adapter.matrix.reset` with `local-only`, `server-leave-forget`, and `--dry-run`, plus tests.
- Task 2: Update `README.md` so restart vs explicit reset workflow is documented and the old manual reset ritual is removed.
-- Phase 02 follow-up, once platform stabilizes: split the current platform boundary into control-plane and direct-agent-session abstractions instead of keeping a single `PlatformClient`.
+- Prototype evaluation follow-up: review the approved spec and plan against the platform repos before starting execution.
+- Future prototype work: introduce `sdk/real.py` plus a narrow compatibility boundary that keeps Matrix adapter logic stable while allowing later expansion toward a fuller platform split.
-- Keep the current consumer-facing bot logic largely intact for now; do not force an early rewrite around the incomplete platform backend.
-- Treat `sdk/mock.py` as a temporary local integration facade, not as a near-drop-in simulation of the real platform.
-- Use Matrix for internal testing while waiting for the platform team to finalize the minimal control-plane contract.
+- Do not integrate with `master` yet; it is still not the backend surfaces needs.
+- Use the direct `agent` WebSocket as the only realistic path for a working prototype right now.
+- Keep consumer-facing Matrix logic as intact as possible and absorb backend differences inside a shim under `sdk/`.
+- Treat future platform evolution as likely to split into at least two concerns: control-plane access and direct agent session streaming.
+- Keep the prototype in this repo on its own branch.
+- Minimize platform-side changes by patching only `platform/agent` if possible.
-- Platform contract blocker: `agent_api` is concrete enough to study, but `master` still does not expose a stable user/chat/session/settings API for surfaces.
-- Product contract blocker: attachments, settings, webhook-style long task events, and exact session bootstrap flow are still unclear on the platform side.
+- Phase 01.1 itself is not blocked; it is simply paused.
+- Prototype blocker: the `agent` repo currently hardcodes a shared `thread_id`, so per-user/per-chat conversation isolation requires either a small upstream change or a careful workaround.
+- Platform contract blocker remains for the longer-term Phase 02 direction: `master` still lacks stable user/chat/session/settings APIs for surfaces.
-The key mental model from this session: our mock pretends the platform is already a complete backend, but the real platform today is split. There is a usable direct agent WebSocket protocol, and there is a developing master control plane, but they have not converged into the unified SDK shape that the bot currently assumes. Because of that, the right near-term move is not to rush integration, but to preserve momentum with Matrix/internal testing and keep the future integration boundary explicit.
+The important mental model is now stable enough to execute. Full SDK integration through `master` is still premature, but a working Matrix prototype can be built now by talking directly to the `agent` WebSocket and hiding the split backend reality behind `sdk/real.py`. The approved design keeps the prototype in this repo, keeps settings local, and minimizes platform changes by preferring a tiny `platform/agent` patch over broader protocol churn. For evaluation and implementation context, inspect:
+- local spec: `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`
+- local plan: `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`
+- remote repos: `https://git.lambda.coredump.ru/platform/agent`, `https://git.lambda.coredump.ru/platform/master`, `https://git.lambda.coredump.ru/platform/agent_api`
+- local clones: `/tmp/platform-agent`, `/tmp/platform-master`, `/tmp/platform-agent_api`
-Start with one of these, depending on priority:
-1. Execute `01.1-03-PLAN.md` Task 1 and build the Matrix reset CLI.
-2. If returning to platform research, write a concrete draft interface for `MasterClient` + `AgentSession` while leaving consumer adapters unchanged.
+Resume with one of these depending on priority:
+1. Evaluate the approved prototype spec and implementation plan against the live platform repos and decide whether to start in this repo or patch `platform/agent` first.
+2. If staying on roadmap execution, implement `01.1-03-PLAN.md` Task 1 (`adapter.matrix.reset`) first.
+3. If starting prototype execution immediately, begin with Task 1 of `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
diff --git a/.planning/phases/02-prototype/.continue-here.md b/.planning/phases/02-prototype/.continue-here.md
new file mode 100644
index 0000000..a2d4619
--- /dev/null
+++ b/.planning/phases/02-prototype/.continue-here.md
@@ -0,0 +1,72 @@
+---
+phase: 02-prototype
+task: 4
+total_tasks: 4
+status: paused
+last_updated: 2026-04-07T23:54:30.473Z
+---
+
+
+The Matrix direct-agent prototype is implemented and manually proven working on branch `feat/matrix-direct-agent-prototype`. The current code path can log into Matrix, accept invites, provision the first Space/chat tree for a fresh user, and send live text messages to a patched local `platform-agent` over WebSocket. The immediate remaining engineering gap is not feature delivery but resilience: backend/provider failures can still bubble up as `PlatformError` and crash the Matrix bot process.
+
+
+
+
+- Task 1: Added `sdk/agent_session.py` and transport tests for direct WebSocket messaging with collision-safe `thread_key` generation.
+- Task 2: Added `sdk/prototype_state.py` and tests for stable local user mapping, settings defaults, and mutation-safe settings copies.
+- Task 3: Added `sdk/real.py` as the `PlatformClient` implementation, fixed import-time dependency leakage, and aligned thread-key tests to the actual dispatcher contract.
+- Task 4: Wired Matrix runtime selection through `MATRIX_PLATFORM_BACKEND=real`, documented usage in `README.md`, and added dispatcher coverage for real backend selection.
+- Fixed repeat Matrix invites so the bot now `join()`s before the existing-user early return path.
+- Added Russian runbook doc `docs/matrix-direct-agent-prototype-ru.md` and pushed the branch.
+- Manually validated live bring-up using a local patched `external/platform-agent` on port 8000 plus the Matrix homeserver `https://matrix.lambda.coredump.ru`.
+
+
+
+
+- Add graceful degradation for backend/provider failures so `PlatformError` does not crash the Matrix process.
+- Decide whether to upstream or separately push the required `external/platform-agent` patch (`1dca2c1`) that enables WebSocket `thread_id`.
+- Optionally clean up repeat-invite UX if Space/chat reprovisioning should ever happen for already-known users.
+- Optionally prepare a PR from `feat/matrix-direct-agent-prototype`.
+
+
+
+
+- Keep the prototype in this repo, not a separate Matrix-only repo.
+- Keep Matrix adapter logic intact and absorb backend differences inside `sdk/`.
+- Split the real backend into `AgentSessionClient` and `PrototypeStateStore` behind `RealPlatformClient`.
+- Patch only `platform-agent` for per-thread memory instead of changing both `agent` and `agent_api`.
+- Use a serialized collision-safe thread key because Matrix user IDs contain colons.
+- For repeat invites, join the room but do not recreate Space/chat state if the user is already provisioned locally.
+
+
+
+- Technical: provider/backend errors still crash the Matrix bot instead of returning a user-facing failure reply.
+- External: the required `platform-agent` patch exists only in the local clone under `external/` and is not yet upstream.
+- Operational: credentials used during manual bring-up were exposed in-session and should be rotated.
+
+
+
+The important mental model is stable. `platform/master` is still not the backend for surfaces, so the working prototype goes directly to `platform-agent` over `/agent_ws/`. The live setup that worked was:
+- `surfaces-bot` branch: `feat/matrix-direct-agent-prototype`
+- Matrix bot env: `MATRIX_PLATFORM_BACKEND=real`, `AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/`
+- patched local `external/platform-agent` with `thread_id` support
+- provider configured through OpenRouter using model `qwen/qwen3.5-122b-a10b`
+
+Important files:
+- `sdk/agent_session.py`
+- `sdk/prototype_state.py`
+- `sdk/real.py`
+- `adapter/matrix/bot.py`
+- `adapter/matrix/handlers/auth.py`
+- `docs/matrix-direct-agent-prototype-ru.md`
+
+Important local-only dependency:
+- `external/platform-agent` commit `1dca2c1` (`feat: support websocket thread ids`)
+
+Likely running background process at pause time:
+- local `platform-agent` server on port 8000, PID 13499
+
+
+
+Start with the failure path: catch `PlatformError` around Matrix message handling so a bad provider response becomes a normal reply like “backend unavailable, try again later” instead of killing the process. After that, either upstream `external/platform-agent` commit `1dca2c1` or document it as an explicit prerequisite in the platform repo.
+
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.gitkeep b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md
new file mode 100644
index 0000000..a9a712b
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md
@@ -0,0 +1,626 @@
+---
+phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - sdk/agent_api_wrapper.py
+ - sdk/agent_session.py
+ - sdk/real.py
+ - adapter/matrix/bot.py
+ - tests/platform/test_agent_session.py
+ - tests/platform/test_real.py
+ - tests/adapter/matrix/test_dispatcher.py
+autonomous: true
+requirements:
+ - Replace AgentSessionClient with AgentApi
+ - Wire AgentApi lifecycle into MatrixBot
+
+must_haves:
+ truths:
+ - "RealPlatformClient uses AgentApiWrapper (wraps AgentApi), not AgentSessionClient"
+ - "AgentApiWrapper is connected before sync_forever and closed in finally block of main()"
+ - "build_thread_key and AgentSessionClient are gone from sdk/"
+ - "stream_message() yields MessageChunk objects including a final chunk with tokens_used from last_tokens_used"
+ - "AGENT_WS_URL is used unchanged (no thread_id query param)"
+ - "MATRIX_PLATFORM_BACKEND=real still works end-to-end without test crash"
+ - "All existing tests pass after the swap"
+ artifacts:
+ - path: "sdk/agent_api_wrapper.py"
+ provides: "AgentApiWrapper subclass of AgentApi with last_tokens_used tracking"
+ contains: "AgentApiWrapper"
+ - path: "sdk/real.py"
+ provides: "RealPlatformClient wrapping AgentApiWrapper"
+ contains: "AgentApiWrapper"
+ - path: "adapter/matrix/bot.py"
+ provides: "main() awaits agent_api.connect() and agent_api.close()"
+ contains: "agent_api.connect"
+ - path: "tests/platform/test_real.py"
+ provides: "Updated tests using FakeAgentApi instead of FakeAgentSessionClient"
+ key_links:
+ - from: "adapter/matrix/bot.py main()"
+ to: "RealPlatformClient._agent_api"
+ via: "runtime.platform.agent_api property"
+ pattern: "agent_api\\.connect"
+ - from: "sdk/real.py stream_message()"
+ to: "agent_api.last_tokens_used"
+ via: "attribute read after async-for loop"
+ pattern: "last_tokens_used"
+---
+
+
+Replace the custom per-request AgentSessionClient with a thin AgentApiWrapper that
+subclasses AgentApi from lambda_agent_api and adds last_tokens_used tracking. Remove
+build_thread_key and AgentSessionClient entirely. Wire AgentApiWrapper connect/close
+into bot.py main(). Update all tests that referenced the old client.
+
+Do NOT modify any file under external/. The external/ directory is managed by the
+platform team. All customisation goes in sdk/agent_api_wrapper.py.
+
+Purpose: The existing AgentSessionClient creates a new WebSocket per message and
+injects thread_id into the URL — both incompatible with origin/main platform-agent.
+AgentApi maintains a single persistent WS connection managed via connect()/close()
+and exposes send_message() as an AsyncIterator. We capture tokens_used in a thin
+subclass so sdk/real.py can include it in the final MessageChunk without touching
+the upstream library.
+
+Output: sdk/agent_api_wrapper.py (new), sdk/real.py (rewritten), sdk/agent_session.py
+(stubbed), adapter/matrix/bot.py updated, tests green.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md
+
+
+
+
+
+
+From external/platform-agent_api/lambda_agent_api/agent_api.py (READ ONLY):
+```python
+class AgentApi:
+ def __init__(self, agent_id: str, url: str,
+ callback=None, on_disconnect=None): ...
+ async def connect(self) -> None: ... # opens WS, awaits MsgStatus, starts _listen task
+ async def close(self) -> None: ... # cancels _listen, closes WS+session
+ async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]:
+ # yields MsgEventTextChunk only; breaks on MsgEventEnd (does NOT yield it)
+ # MsgEventEnd.tokens_used is consumed internally at the break point
+ ...
+ async def _listen(self) -> None:
+ # internal task: receives WS frames, puts AgentEventUnion into self._queue
+ # on MsgEventEnd: puts it in queue then breaks
+ ...
+ # AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd] per server.py
+```
+
+From external/platform-agent_api/lambda_agent_api/server.py (READ ONLY):
+```python
+class MsgEventTextChunk(BaseModel):
+ type: Literal[EServerMessage.AGENT_EVENT_TEXT_CHUNK]
+ text: str
+
+class MsgEventEnd(BaseModel):
+ type: Literal[EServerMessage.AGENT_EVENT_END]
+ tokens_used: int
+```
+
+New file to create — sdk/agent_api_wrapper.py:
+```python
+class AgentApiWrapper(AgentApi):
+ """Thin subclass of AgentApi that captures tokens_used from MsgEventEnd.
+
+ AgentApi.send_message() yields only MsgEventTextChunk and breaks silently
+ on MsgEventEnd without storing tokens_used. This wrapper overrides _listen()
+ to intercept MsgEventEnd and store tokens_used before it is discarded.
+ """
+ last_tokens_used: int = 0
+
+ async def _listen(self) -> None:
+ # Override: same as parent, but capture MsgEventEnd.tokens_used
+ ...
+```
+
+From sdk/interface.py (unchanged):
+```python
+class MessageChunk(BaseModel):
+ message_id: str
+ delta: str
+ finished: bool
+ tokens_used: int = 0
+
+class PlatformClient(Protocol):
+ async def send_message(self, user_id, chat_id, text, attachments=None) -> MessageResponse: ...
+ async def stream_message(self, user_id, chat_id, text, attachments=None) -> AsyncIterator[MessageChunk]: ...
+```
+
+
+
+
+
+ Task 1: Create sdk/agent_api_wrapper.py; rewrite sdk/real.py; stub sdk/agent_session.py
+
+
+ - sdk/real.py (full file — being replaced)
+ - sdk/agent_session.py (full file — being stubbed)
+ - external/platform-agent_api/lambda_agent_api/agent_api.py (full file — READ ONLY, inspect _listen and send_message to understand override point)
+ - external/platform-agent_api/lambda_agent_api/server.py (full file — READ ONLY, MsgEventEnd.tokens_used)
+ - sdk/interface.py (MessageChunk, PlatformClient Protocol)
+
+
+ sdk/agent_api_wrapper.py, sdk/real.py, sdk/agent_session.py
+
+
+ - Create sdk/agent_api_wrapper.py with class AgentApiWrapper(AgentApi):
+ - __init__: calls super().__init__(...) and adds self.last_tokens_used: int = 0
+ - Override _listen(): copy the parent _listen() logic verbatim, then at the point where MsgEventEnd is received (before or as it is put into the queue), set self.last_tokens_used = chunk.tokens_used
+ - Do NOT modify agent_api.py in external/ — subclass only
+ - RealPlatformClient.__init__ accepts agent_api: AgentApiWrapper, prototype_state: PrototypeStateStore, platform: str = "matrix"
+ - RealPlatformClient exposes agent_api as property self.agent_api so bot.py main() can call connect/close
+ - stream_message() iterates agent_api.send_message(text) yielding MessageChunk per MsgEventTextChunk chunk; after loop yields final MessageChunk(finished=True, delta="", tokens_used=agent_api.last_tokens_used)
+ - send_message() collects all chunks from stream_message() and returns MessageResponse
+ - No thread_key, no build_thread_key references anywhere in sdk/real.py
+ - sdk/agent_session.py: replace file contents with single comment stub (keep file to avoid import errors until tests are updated in Task 2)
+
+
+
+1. Read external/platform-agent_api/lambda_agent_api/agent_api.py fully to find the exact _listen() implementation and the line where MsgEventEnd is handled.
+
+2. Create sdk/agent_api_wrapper.py:
+```python
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+# Ensure lambda_agent_api is importable (same sys.path trick as bot.py)
+_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.agent_api import AgentApi
+from lambda_agent_api.server import MsgEventEnd
+
+
+class AgentApiWrapper(AgentApi):
+ """Thin subclass of AgentApi that captures tokens_used from MsgEventEnd.
+
+ AgentApi.send_message() yields MsgEventTextChunk events and breaks on
+ MsgEventEnd without storing tokens_used. This wrapper overrides _listen()
+ to intercept MsgEventEnd and set self.last_tokens_used before the event
+ is discarded, so RealPlatformClient can include it in the final MessageChunk.
+
+ Do NOT modify external/platform-agent_api — subclass only.
+ """
+
+ def __init__(self, agent_id: str, url: str, **kwargs) -> None:
+ super().__init__(agent_id=agent_id, url=url, **kwargs)
+ self.last_tokens_used: int = 0
+
+ async def _listen(self) -> None:
+ # Copy parent _listen() logic.
+ # Read external/platform-agent_api/lambda_agent_api/agent_api.py _listen()
+ # and reproduce it here, adding:
+ # if isinstance(event, MsgEventEnd):
+ # self.last_tokens_used = event.tokens_used
+ # at the point where MsgEventEnd is processed.
+ #
+ # IMPORTANT: after reading agent_api.py, replace this entire method body
+ # with the exact parent implementation + the tokens_used capture line.
+ # Do not call super()._listen() — the parent creates a task; we need the
+ # override to run in the same task context.
+ raise NotImplementedError(
+ "Executor: replace this body with the copied _listen() from AgentApi "
+ "plus `self.last_tokens_used = event.tokens_used` at the MsgEventEnd branch."
+ )
+```
+
+ IMPORTANT NOTE FOR EXECUTOR: The `_listen()` body above is a placeholder.
+ After reading agent_api.py, copy the actual _listen() implementation from AgentApi
+ into AgentApiWrapper._listen() and insert `self.last_tokens_used = event.tokens_used`
+ at the MsgEventEnd branch. The final file must NOT contain the NotImplementedError.
+
+3. Rewrite sdk/real.py entirely:
+```python
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, AsyncIterator
+
+from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+
+if TYPE_CHECKING:
+ from sdk.agent_api_wrapper import AgentApiWrapper
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_api: "AgentApiWrapper",
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+ self._agent_api = agent_api
+ self._prototype_state = prototype_state
+ self._platform = platform
+
+ @property
+ def agent_api(self) -> "AgentApiWrapper":
+ return self._agent_api
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ return await self._prototype_state.get_or_create_user(
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ parts: list[str] = []
+ tokens_used = 0
+ async for chunk in self.stream_message(user_id, chat_id, text, attachments):
+ if chunk.delta:
+ parts.append(chunk.delta)
+ if chunk.finished:
+ tokens_used = chunk.tokens_used
+ return MessageResponse(
+ message_id=user_id,
+ response="".join(parts),
+ tokens_used=tokens_used,
+ finished=True,
+ )
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ from lambda_agent_api.server import MsgEventTextChunk
+ async for event in self._agent_api.send_message(text):
+ if isinstance(event, MsgEventTextChunk):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=self._agent_api.last_tokens_used,
+ )
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ return await self._prototype_state.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action) -> None:
+ await self._prototype_state.update_settings(user_id, action)
+```
+
+4. Replace sdk/agent_session.py content with:
+```python
+# Deleted in Phase 4 — replaced by AgentApiWrapper from sdk/agent_api_wrapper.py
+# File kept as stub to avoid import errors during migration; remove after test_agent_session.py is updated.
+```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from sdk.real import RealPlatformClient; print('import ok')"
+
+
+
+ - sdk/agent_api_wrapper.py exists with AgentApiWrapper(AgentApi), __init__ sets self.last_tokens_used = 0, _listen() override captures MsgEventEnd.tokens_used
+ - sdk/real.py imports AgentApiWrapper (not AgentSessionClient or AgentApi directly), exposes self.agent_api property
+ - sdk/real.py stream_message yields final chunk with tokens_used from agent_api.last_tokens_used
+ - external/ directory has NO modifications
+ - sdk/agent_session.py contains only a comment stub (no class definitions)
+ - `python -c "from sdk.real import RealPlatformClient"` exits 0
+ - `grep "AgentApiWrapper" sdk/real.py` returns a match
+ - `grep "last_tokens_used" sdk/agent_api_wrapper.py` returns a match
+
+
+
+
+ Task 2: Wire AgentApiWrapper lifecycle into bot.py main(); update all broken tests
+
+
+ - adapter/matrix/bot.py (full file — _build_platform_from_env and main() need changes)
+ - tests/platform/test_agent_session.py (full file — delete or rewrite)
+ - tests/platform/test_real.py (full file — FakeAgentSessionClient → FakeAgentApi)
+ - tests/adapter/matrix/test_dispatcher.py (test_build_runtime_uses_real_platform — needs update)
+
+
+ adapter/matrix/bot.py, tests/platform/test_agent_session.py, tests/platform/test_real.py, tests/adapter/matrix/test_dispatcher.py
+
+
+ - _build_platform_from_env() returns a RealPlatformClient with an unconnected AgentApiWrapper (connect() NOT called here — called in main())
+ - main() calls await runtime.platform.agent_api.connect() after build_runtime() (only when backend is "real"; mock has no agent_api); wrap in `if hasattr(runtime.platform, "agent_api")` guard
+ - main() finally block: await agent_api.close() before await client.close()
+ - AGENT_WS_URL env var is passed unchanged to AgentApiWrapper(url=ws_url) — no query param manipulation
+ - test_agent_session.py: completely rewritten — remove all build_thread_key tests, remove AgentSessionClient tests; replace with 2 tests: (1) import check for lambda_agent_api module, (2) stub test that documents the deletion
+ - test_real.py: FakeAgentSessionClient replaced with FakeAgentApi that has send_message(text: str) -> AsyncIterator and last_tokens_used: int = 0; tests updated to construct RealPlatformClient(agent_api=FakeAgentApi(), prototype_state=PrototypeStateStore()); test_send_message no longer checks thread_key in message_id (now uses user_id); test_stream_message checks final chunk tokens_used comes from FakeAgentApi.last_tokens_used
+ - test_dispatcher.py: test_build_runtime_uses_real_platform_when_matrix_backend_is_real must NOT call agent_api.connect() (build_runtime only constructs, does not connect); update test to mock AgentApiWrapper so it does not attempt a real WS connection; assert isinstance(runtime.platform, RealPlatformClient) still passes
+
+
+
+1. Edit adapter/matrix/bot.py:
+
+ a. Remove imports: `from sdk.agent_session import AgentSessionClient, AgentSessionConfig`
+
+ b. In _build_platform_from_env(), use AgentApiWrapper with lazy import:
+ ```python
+ def _build_platform_from_env() -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if backend == "real":
+ import sys
+ _api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
+ if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+ from sdk.agent_api_wrapper import AgentApiWrapper
+ ws_url = os.environ["AGENT_WS_URL"]
+ agent_api = AgentApiWrapper(agent_id="matrix-bot", url=ws_url)
+ return RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ return MockPlatformClient()
+ ```
+
+ c. In main(), after `runtime = build_runtime(store=SQLiteStore(db_path), client=client)`, add:
+ ```python
+ if hasattr(runtime.platform, "agent_api"):
+ await runtime.platform.agent_api.connect()
+ ```
+
+ d. In main() finally block, add before `await client.close()`:
+ ```python
+ if hasattr(runtime.platform, "agent_api"):
+ await runtime.platform.agent_api.close()
+ ```
+
+2. Rewrite tests/platform/test_agent_session.py:
+```python
+"""
+test_agent_session.py — stub after Phase 4 migration.
+
+AgentSessionClient and build_thread_key were removed in Phase 4.
+The platform client is now AgentApiWrapper wrapping AgentApi from lambda_agent_api.
+See tests/platform/test_real.py for RealPlatformClient tests.
+"""
+import sys
+from pathlib import Path
+
+_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+
+def test_lambda_agent_api_module_importable():
+ from lambda_agent_api.agent_api import AgentApi # noqa: F401
+ from lambda_agent_api.server import MsgEventTextChunk, MsgEventEnd # noqa: F401
+ assert True
+
+
+def test_agent_session_module_is_stub():
+ """Ensure old module no longer exposes AgentSessionClient or build_thread_key."""
+ import sdk.agent_session as mod
+ assert not hasattr(mod, "AgentSessionClient"), "AgentSessionClient should be removed"
+ assert not hasattr(mod, "build_thread_key"), "build_thread_key should be removed"
+```
+
+3. Rewrite tests/platform/test_real.py:
+```python
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from typing import AsyncIterator
+
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.interface import MessageChunk, MessageResponse, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+
+_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.server import MsgEventTextChunk, EServerMessage # noqa: E402
+
+
+class FakeAgentApi:
+ """Minimal fake for AgentApiWrapper — no real WebSocket."""
+ def __init__(self) -> None:
+ self.last_tokens_used: int = 0
+ self.send_calls: list[str] = []
+
+ async def send_message(self, text: str) -> AsyncIterator[MsgEventTextChunk]:
+ self.send_calls.append(text)
+ self.last_tokens_used = 7
+ yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[:2])
+ yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[2:])
+ # send_message() in real AgentApi breaks on MsgEventEnd without yielding it;
+ # FakeAgentApi mirrors this by not yielding MsgEventEnd — last_tokens_used is set directly.
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_get_or_create_user_uses_local_state():
+ client = RealPlatformClient(
+ agent_api=FakeAgentApi(),
+ prototype_state=PrototypeStateStore(),
+ )
+ first = await client.get_or_create_user("u1", "matrix", "Alice")
+ second = await client.get_or_create_user("u1", "matrix")
+
+ assert first.user_id == "usr-matrix-u1"
+ assert first.is_new is True
+ assert second.user_id == first.user_id
+ assert second.is_new is False
+ assert second.display_name == "Alice"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_calls_agent_with_text():
+ fake = FakeAgentApi()
+ client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore())
+
+ result = await client.send_message("@alice:example.org", "C1", "hello")
+
+ assert result.response == "hello"
+ assert result.tokens_used == 7
+ assert fake.send_calls == ["hello"]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_stream_message_yields_chunks_and_final_with_tokens():
+ fake = FakeAgentApi()
+ client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore())
+
+ chunks = []
+ async for chunk in client.stream_message("@alice:example.org", "C1", "hello"):
+ chunks.append(chunk)
+
+ assert chunks[-1].finished is True
+ assert chunks[-1].tokens_used == 7
+ assert "".join(c.delta for c in chunks) == "hello"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_settings_are_local():
+ client = RealPlatformClient(
+ agent_api=FakeAgentApi(),
+ prototype_state=PrototypeStateStore(),
+ )
+ await client.update_settings(
+ "usr-matrix-u1",
+ SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
+ )
+ settings = await client.get_settings("usr-matrix-u1")
+ assert isinstance(settings, UserSettings)
+ assert settings.skills["browser"] is True
+```
+
+4. Edit tests/adapter/matrix/test_dispatcher.py — update `test_build_runtime_uses_real_platform_when_matrix_backend_is_real`:
+ - Add sys.path setup for lambda_agent_api (same pattern as above)
+ - Mock AgentApiWrapper so it does not open a real WS:
+ ```python
+ async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
+ import sys
+ from pathlib import Path
+ _api_root = Path(__file__).resolve().parents[3] / "external" / "platform-agent_api"
+ if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
+
+ # Patch AgentApiWrapper to avoid real WS connection during build_runtime
+ import sdk.agent_api_wrapper as _mod
+ class _FakeAgentApiWrapper:
+ def __init__(self, agent_id, url, **kw):
+ self.last_tokens_used = 0
+ async def connect(self): pass
+ async def close(self): pass
+ async def send_message(self, text):
+ return; yield # empty async generator
+ monkeypatch.setattr(_mod, "AgentApiWrapper", _FakeAgentApiWrapper)
+
+ from adapter.matrix.bot import build_runtime
+ from sdk.real import RealPlatformClient
+ runtime = build_runtime()
+ assert isinstance(runtime.platform, RealPlatformClient)
+ ```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_agent_session.py tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v 2>&1 | tail -20
+
+
+
+ - All tests in test_agent_session.py, test_real.py, test_dispatcher.py pass
+ - main() in bot.py has agent_api.connect() call guarded by hasattr check
+ - main() finally block closes agent_api before matrix client
+ - grep confirms no "AgentSessionClient" or "build_thread_key" remain in sdk/real.py or adapter/matrix/bot.py
+ - grep confirms no modifications to any file under external/
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| bot → platform-agent WS | Outbound WS to agent service; input is user text |
+| env vars → bot config | AGENT_WS_URL, MATRIX_PLATFORM_BACKEND read from environment |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-01-01 | Tampering | AgentApiWrapper.send_message() text | accept | Single-user prototype; text originates from authenticated Matrix user |
+| T-04-01-02 | Denial of Service | AgentBusyException from concurrent sends | mitigate | AgentApi._request_lock already prevents concurrent sends; bot must surface error to user instead of crashing |
+| T-04-01-03 | Information Disclosure | AGENT_WS_URL in env | accept | Internal service URL; not exposed to users |
+
+
+
+Run full test suite after both tasks complete:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30
+```
+
+Grep checks:
+```bash
+# No old imports should remain
+grep -r "AgentSessionClient\|build_thread_key" sdk/ adapter/ tests/ --include="*.py" | grep -v "stub\|Deleted\|removed"
+
+# AgentApiWrapper wired in bot.py
+grep "agent_api.connect\|agent_api.close" adapter/matrix/bot.py
+
+# last_tokens_used set in wrapper
+grep "last_tokens_used" sdk/agent_api_wrapper.py
+
+# No external/ files modified
+git diff --name-only external/
+```
+
+
+
+- `pytest tests/platform/ tests/adapter/matrix/test_dispatcher.py -v` exits 0 with no failures
+- `grep -r "AgentSessionClient" sdk/ adapter/` returns empty (or only the stub comment)
+- `grep -r "build_thread_key" sdk/ adapter/` returns empty
+- `grep "agent_api.connect" adapter/matrix/bot.py` returns a match
+- `grep "last_tokens_used" sdk/agent_api_wrapper.py` returns the assignment line
+- `git diff --name-only external/` returns empty (external/ untouched)
+
+
+
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md
new file mode 100644
index 0000000..dcd6114
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md
@@ -0,0 +1,29 @@
+# 04-01 Summary
+
+## Outcome
+
+Replaced the Matrix real backend's custom `AgentSessionClient` path with a shared
+`AgentApiWrapper` over upstream `lambda_agent_api.AgentApi`.
+
+## Changes
+
+- Added `sdk/agent_api_wrapper.py` to capture `MsgEventEnd.tokens_used` without
+ modifying `external/`.
+- Rewrote `sdk/real.py` to use a shared `agent_api`, stream text chunks from
+ `AgentApi.send_message()`, and emit a final `MessageChunk` with
+ `last_tokens_used`.
+- Updated `adapter/matrix/bot.py` to construct `RealPlatformClient` with
+ `AgentApiWrapper`, keep `AGENT_WS_URL` unchanged, and manage
+ `agent_api.connect()` / `agent_api.close()` around `sync_forever()`.
+- Stubbed `sdk/agent_session.py` as a compatibility placeholder.
+- Updated Matrix/runtime tests away from `thread_key` and per-request websocket
+ assumptions.
+
+## Verification
+
+- `pytest tests/platform/test_real.py -q`
+- `pytest tests/adapter/matrix/test_dispatcher.py -q`
+- `pytest tests/core/test_integration.py -q`
+- `pytest tests/platform/test_agent_session.py -q`
+
+All listed commands passed locally.
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md
new file mode 100644
index 0000000..1b16918
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md
@@ -0,0 +1,865 @@
+---
+phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - 04-01-PLAN.md
+files_modified:
+ - sdk/prototype_state.py
+ - adapter/matrix/store.py
+ - adapter/matrix/handlers/__init__.py
+ - adapter/matrix/handlers/context_commands.py
+ - adapter/matrix/bot.py
+ - tests/adapter/matrix/test_context_commands.py
+ - tests/platform/test_prototype_state.py
+autonomous: true
+requirements:
+ - Implement !save, !load, !reset, !context commands
+ - PrototypeStateStore saved sessions storage
+ - !load pending state in Matrix store
+ - !reset pending state in Matrix store
+ - Numeric input interception for !load
+
+must_haves:
+ truths:
+ - "!save sends a save prompt to the agent and records session name in PrototypeStateStore"
+ - "!load shows a numbered list of saved sessions; numeric reply selects a session"
+ - "!reset shows a confirmation dialog; !yes calls POST /reset; !no cancels"
+ - "!context returns current session name, last tokens_used, and list of saved sessions"
+ - "Numeric input intercepted in on_room_message before dispatcher.dispatch when load_pending is set"
+ - "!yes in reset_pending context calls POST {AGENT_BASE_URL}/reset and reports unavailable on 404"
+ - "All context command tests pass"
+ artifacts:
+ - path: "adapter/matrix/handlers/context_commands.py"
+ provides: "make_handle_save, make_handle_load, make_handle_reset, make_handle_context"
+ - path: "adapter/matrix/store.py"
+ provides: "get_load_pending, set_load_pending, clear_load_pending, get_reset_pending, set_reset_pending, clear_reset_pending"
+ - path: "sdk/prototype_state.py"
+ provides: "add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used"
+ - path: "tests/adapter/matrix/test_context_commands.py"
+ provides: "tests for all four commands"
+ key_links:
+ - from: "adapter/matrix/bot.py on_room_message()"
+ to: "adapter/matrix/store.get_load_pending()"
+ via: "check before dispatcher.dispatch"
+ pattern: "get_load_pending"
+ - from: "adapter/matrix/handlers/context_commands.py make_handle_reset"
+ to: "httpx.AsyncClient.post(AGENT_BASE_URL + '/reset')"
+ via: "!yes handler inside reset_pending flow"
+ pattern: "httpx"
+ - from: "sdk/real.py stream_message()"
+ to: "prototype_state.set_last_tokens_used()"
+ via: "call after final chunk"
+ pattern: "set_last_tokens_used"
+---
+
+
+Add four context management commands to the Matrix bot: !save, !load, !reset, !context.
+Extend PrototypeStateStore with saved sessions and last_tokens_used tracking. Add
+load_pending and reset_pending state keys to Matrix store. Wire numeric input
+interception in on_room_message. Register all handlers.
+
+Purpose: Users need to save, load, and reset agent context, and inspect current context
+state — essential for a shared-context MVP where one agent container persists across
+Matrix sessions.
+
+Output: context_commands.py handler module, store.py extensions, prototype_state.py
+extensions, bot.py updated, full test coverage.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md
+
+
+
+
+
+From adapter/matrix/store.py (existing pattern):
+```python
+PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
+
+def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str: ...
+async def get_pending_confirm(store, user_id, room_id=None) -> dict | None: ...
+async def set_pending_confirm(store, user_id, room_id, meta) -> None: ...
+async def clear_pending_confirm(store, user_id, room_id=None) -> None: ...
+```
+
+New store keys to add (same pattern):
+```python
+LOAD_PENDING_PREFIX = "matrix_load_pending:"
+RESET_PENDING_PREFIX = "matrix_reset_pending:"
+
+# Keys: f"{PREFIX}{user_id}:{room_id}"
+# load_pending data: {"saves": [{"name": str, "created_at": str}, ...], "display": str}
+# reset_pending data: {"active": True}
+```
+
+From adapter/matrix/handlers/__init__.py (existing registration):
+```python
+def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None:
+ dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
+ ...
+```
+
+Handler closure signature (all existing handlers follow this):
+```python
+async def handle_X(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]:
+```
+
+New handlers use make_handle_X(agent_api, store, prototype_state) closures:
+```python
+async def _inner(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]:
+ ...
+return _inner
+```
+
+From sdk/prototype_state.py (PrototypeStateStore to extend):
+```python
+class PrototypeStateStore:
+ def __init__(self) -> None:
+ self._users: dict[str, User] = {}
+ self._settings: dict[str, dict[str, Any]] = {}
+ # Add:
+ # self._saved_sessions: dict[str, list[dict]] = {}
+ # self._last_tokens_used: dict[str, int] = {}
+```
+
+From core/protocol.py:
+```python
+@dataclass
+class IncomingCommand:
+ user_id: str; platform: str; chat_id: str; command: str; args: list[str]
+
+@dataclass
+class OutgoingMessage:
+ chat_id: str; text: str
+
+@dataclass
+class OutgoingUI:
+ chat_id: str; text: str; buttons: list[UIButton]
+```
+
+From sdk/real.py (after Plan 01):
+```python
+class RealPlatformClient:
+ async def stream_message(self, user_id, chat_id, text, ...) -> AsyncIterator[MessageChunk]:
+ # yields chunks; last chunk has finished=True, tokens_used=agent_api.last_tokens_used
+```
+
+SAVE_PROMPT template (Claude's Discretion):
+```python
+SAVE_PROMPT = (
+ "Summarize our conversation and save to /workspace/contexts/{name}.md. "
+ "Reply only with: Saved: {name}"
+)
+
+LOAD_PROMPT = (
+ "Load context from /workspace/contexts/{name}.md and use it as background "
+ "for our conversation. Reply: Loaded: {name}"
+)
+```
+
+Auto-name format for !save without args: `context-{YYYYMMDD-HHMMSS}` UTC.
+HTTP client for POST /reset: httpx.AsyncClient (already in pyproject.toml deps).
+AGENT_BASE_URL env var: `os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")`
+
+
+
+
+
+ Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers
+
+
+ - sdk/prototype_state.py (full file — adding saved_sessions and last_tokens_used)
+ - adapter/matrix/store.py (full file — adding load_pending and reset_pending helpers)
+ - tests/platform/test_prototype_state.py (full file — adding new test cases)
+
+
+ sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py
+
+
+ - PrototypeStateStore.__init__ adds: self._saved_sessions: dict[str, list[dict]] = {} and self._last_tokens_used: dict[str, int] = {}
+ - add_saved_session(user_id: str, name: str) -> None: appends {"name": name, "created_at": datetime.now(UTC).isoformat()} to _saved_sessions[user_id]
+ - list_saved_sessions(user_id: str) -> list[dict]: returns copy of _saved_sessions.get(user_id, [])
+ - get_last_tokens_used(user_id: str) -> int: returns _last_tokens_used.get(user_id, 0)
+ - set_last_tokens_used(user_id: str, tokens: int) -> None: sets _last_tokens_used[user_id] = tokens
+ - adapter/matrix/store.py adds LOAD_PENDING_PREFIX and RESET_PENDING_PREFIX constants
+ - get_load_pending(store, user_id, room_id) -> dict | None
+ - set_load_pending(store, user_id, room_id, data: dict) -> None
+ - clear_load_pending(store, user_id, room_id) -> None
+ - get_reset_pending(store, user_id, room_id) -> dict | None
+ - set_reset_pending(store, user_id, room_id, data: dict) -> None
+ - clear_reset_pending(store, user_id, room_id) -> None
+ - test_prototype_state.py gets 4 new tests: add/list saved sessions, last_tokens_used get/set
+
+
+
+1. Edit sdk/prototype_state.py — add to __init__ and add four new async methods:
+
+In __init__ after existing attributes:
+```python
+ self._saved_sessions: dict[str, list[dict]] = {}
+ self._last_tokens_used: dict[str, int] = {}
+```
+
+After update_settings() method, add:
+```python
+ async def add_saved_session(self, user_id: str, name: str) -> None:
+ sessions = self._saved_sessions.setdefault(user_id, [])
+ sessions.append({"name": name, "created_at": datetime.now(UTC).isoformat()})
+
+ async def list_saved_sessions(self, user_id: str) -> list[dict]:
+ return list(self._saved_sessions.get(user_id, []))
+
+ async def get_last_tokens_used(self, user_id: str) -> int:
+ return self._last_tokens_used.get(user_id, 0)
+
+ async def set_last_tokens_used(self, user_id: str, tokens: int) -> None:
+ self._last_tokens_used[user_id] = tokens
+```
+
+2. Edit adapter/matrix/store.py — add after existing constants and helpers:
+
+After PENDING_CONFIRM_PREFIX line, add:
+```python
+LOAD_PENDING_PREFIX = "matrix_load_pending:"
+RESET_PENDING_PREFIX = "matrix_reset_pending:"
+```
+
+After clear_pending_confirm(), add:
+```python
+def _load_pending_key(user_id: str, room_id: str) -> str:
+ return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}"
+
+async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
+ return await store.get(_load_pending_key(user_id, room_id))
+
+async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
+ await store.set(_load_pending_key(user_id, room_id), data)
+
+async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None:
+ await store.delete(_load_pending_key(user_id, room_id))
+
+
+def _reset_pending_key(user_id: str, room_id: str) -> str:
+ return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}"
+
+async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
+ return await store.get(_reset_pending_key(user_id, room_id))
+
+async def set_reset_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
+ await store.set(_reset_pending_key(user_id, room_id), data)
+
+async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None:
+ await store.delete(_reset_pending_key(user_id, room_id))
+```
+
+3. Edit tests/platform/test_prototype_state.py — append four new tests:
+
+```python
+@pytest.mark.asyncio
+async def test_saved_sessions_add_and_list():
+ store = PrototypeStateStore()
+ await store.add_saved_session("u1", "my-save")
+ await store.add_saved_session("u1", "another-save")
+ sessions = await store.list_saved_sessions("u1")
+ assert len(sessions) == 2
+ assert sessions[0]["name"] == "my-save"
+ assert "created_at" in sessions[0]
+ assert sessions[1]["name"] == "another-save"
+
+
+@pytest.mark.asyncio
+async def test_saved_sessions_list_returns_copy():
+ store = PrototypeStateStore()
+ await store.add_saved_session("u1", "my-save")
+ sessions = await store.list_saved_sessions("u1")
+ sessions.append({"name": "injected"})
+ sessions2 = await store.list_saved_sessions("u1")
+ assert len(sessions2) == 1
+
+
+@pytest.mark.asyncio
+async def test_last_tokens_used_default_zero():
+ store = PrototypeStateStore()
+ assert await store.get_last_tokens_used("u1") == 0
+
+
+@pytest.mark.asyncio
+async def test_last_tokens_used_set_and_get():
+ store = PrototypeStateStore()
+ await store.set_last_tokens_used("u1", 42)
+ assert await store.get_last_tokens_used("u1") == 42
+```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15
+
+
+
+ - PrototypeStateStore has add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used
+ - adapter/matrix/store.py has LOAD_PENDING_PREFIX, RESET_PENDING_PREFIX, and 6 new helper functions
+ - All test_prototype_state.py tests pass (including 4 new ones)
+ - `grep "add_saved_session\|list_saved_sessions" sdk/prototype_state.py` returns matches
+ - `grep "LOAD_PENDING_PREFIX\|RESET_PENDING_PREFIX" adapter/matrix/store.py` returns matches
+
+
+
+
+ Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py
+
+
+ - adapter/matrix/handlers/__init__.py (full file — adding registrations)
+ - adapter/matrix/handlers/confirm.py (full file — example of make_handle_X closure pattern with store)
+ - adapter/matrix/bot.py (full file — on_room_message and build_runtime need changes)
+ - sdk/real.py (after Plan 01 — add set_last_tokens_used call after stream_message)
+ - adapter/matrix/store.py (after Task 1 — load_pending/reset_pending helpers now available)
+ - sdk/prototype_state.py (after Task 1 — saved_sessions methods available)
+
+
+
+ adapter/matrix/handlers/context_commands.py,
+ adapter/matrix/handlers/__init__.py,
+ adapter/matrix/bot.py,
+ sdk/real.py,
+ tests/adapter/matrix/test_context_commands.py
+
+
+
+ - context_commands.py exports: make_handle_save, make_handle_load, make_handle_reset, make_handle_context
+ - make_handle_save(agent_api, store, prototype_state) -> handler:
+ !save with no args: auto-name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
+ !save [name]: use args[0] as name
+ sends SAVE_PROMPT via platform.send_message (NOT stream — simple blocking send)
+ calls prototype_state.add_saved_session(event.user_id, name)
+ returns [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")]
+ - make_handle_load(agent_api, store, prototype_state) -> handler:
+ !load: fetches sessions = await prototype_state.list_saved_sessions(event.user_id)
+ if empty: returns [OutgoingMessage(chat_id=..., text="Нет сохранённых сессий. Используй !save [имя].")]
+ else: builds numbered display text, stores load_pending via set_load_pending(store, event.user_id, room_id, {"saves": sessions})
+ room_id is in event.chat_id (in Matrix adapter, chat_id == room_id for commands)
+ returns [OutgoingMessage(chat_id=..., text=display_text + "\nВведи номер или 0 / !cancel для отмены.")]
+ - Numeric input interception in MatrixBot.on_room_message():
+ Before dispatcher.dispatch, check load_pending = await get_load_pending(runtime.store, sender, room_id)
+ If load_pending and msg text is digit: handle_load_selection(pending, selection, ...)
+ handle_load_selection: if text == "0" or "!cancel" → clear_load_pending, return [OutgoingMessage("Отменено")]
+ if valid index → clear_load_pending, send LOAD_PROMPT via platform.send_message, add session as current_session in prototype_state (store in dict), return [OutgoingMessage("Загрузка: {name}")]
+ if invalid index → return [OutgoingMessage("Неверный номер. Введи от 1 до N или 0 для отмены.")]
+ - make_handle_reset(store, agent_base_url) -> handler:
+ !reset: set reset_pending, return [OutgoingMessage with text:
+ "Сбросить контекст агента? Выбери:\n !yes — сбросить\n !save [имя] — сохранить и сбросить\n !no — отмена")]
+ !yes in reset_pending: call POST {AGENT_BASE_URL}/reset via httpx; if 404 or connection error → "Reset endpoint недоступен. Обратитесь к администратору."; else "Контекст сброшен."; clear reset_pending
+ !no in reset_pending: clear reset_pending, return [OutgoingMessage("Отменено.")]
+ !save имя in reset_pending: delegate to save logic, then POST /reset (same fallback)
+ Reset_pending check must happen BEFORE pending_confirm in handler priority — implement by checking reset_pending in the !yes and !no handlers (make_handle_confirm must check reset_pending first)
+ - make_handle_context(store, prototype_state) -> handler:
+ reads current_session from prototype_state._current_session dict (keyed by user_id) if it exists
+ reads tokens = await prototype_state.get_last_tokens_used(event.user_id)
+ reads sessions = await prototype_state.list_saved_sessions(event.user_id)
+ formats: "Контекст:\n Сессия: {name or 'не загружена'}\n Токены (последний ответ): {tokens}\n Сохранения ({len}):\n {list}"
+ returns [OutgoingMessage(chat_id=..., text=formatted)]
+ - sdk/real.py: after the final yield in stream_message, call await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) — needs prototype_state reference already present in RealPlatformClient
+ - PrototypeStateStore gets one more dict: self._current_session: dict[str, str] = {} and methods get_current_session(user_id) -> str | None, set_current_session(user_id, name) -> None
+ - register_matrix_handlers() updated to accept agent_api and agent_base_url params; registers save/load/reset/context
+
+
+
+1. Add to sdk/prototype_state.py __init__: `self._current_session: dict[str, str] = {}`
+ Add methods:
+ ```python
+ async def get_current_session(self, user_id: str) -> str | None:
+ return self._current_session.get(user_id)
+
+ async def set_current_session(self, user_id: str, name: str) -> None:
+ self._current_session[user_id] = name
+ ```
+
+2. Create adapter/matrix/handlers/context_commands.py:
+
+```python
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+
+import httpx
+import structlog
+
+from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage
+
+if TYPE_CHECKING:
+ from lambda_agent_api.agent_api import AgentApi
+ from sdk.prototype_state import PrototypeStateStore
+ from core.store import StateStore
+
+logger = structlog.get_logger(__name__)
+
+SAVE_PROMPT = (
+ "Summarize our conversation and save to /workspace/contexts/{name}.md. "
+ "Reply only with: Saved: {name}"
+)
+
+LOAD_PROMPT = (
+ "Load context from /workspace/contexts/{name}.md and use it as background "
+ "for our conversation. Reply: Loaded: {name}"
+)
+
+
+def make_handle_save(agent_api: "AgentApi", store: "StateStore", prototype_state: "PrototypeStateStore"):
+ async def handle_save(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ if event.args:
+ name = event.args[0]
+ else:
+ name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
+
+ prompt = SAVE_PROMPT.format(name=name)
+ try:
+ await platform.send_message(event.user_id, event.chat_id, prompt)
+ except Exception as exc:
+ logger.warning("save_agent_call_failed", error=str(exc))
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")]
+
+ await prototype_state.add_saved_session(event.user_id, name)
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")]
+
+ return handle_save
+
+
+def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"):
+ async def handle_load(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ from adapter.matrix.store import set_load_pending
+
+ sessions = await prototype_state.list_saved_sessions(event.user_id)
+ if not sessions:
+ return [OutgoingMessage(
+ chat_id=event.chat_id,
+ text="Нет сохранённых сессий. Используй !save [имя].",
+ )]
+
+ lines = ["Сохранённые сессии:"]
+ for i, s in enumerate(sessions, start=1):
+ created = s.get("created_at", "")[:10]
+ lines.append(f" {i}. {s['name']} ({created})")
+ lines.append("\nВведи номер или 0 / !cancel для отмены.")
+ display = "\n".join(lines)
+
+ await set_load_pending(store, event.user_id, event.chat_id, {"saves": sessions})
+ return [OutgoingMessage(chat_id=event.chat_id, text=display)]
+
+ return handle_load
+
+
+def make_handle_reset(store: "StateStore", agent_base_url: str):
+ async def handle_reset(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ from adapter.matrix.store import set_reset_pending
+
+ await set_reset_pending(store, event.user_id, event.chat_id, {"active": True})
+ text = (
+ "Сбросить контекст агента? Выбери:\n"
+ " !yes — сбросить\n"
+ " !save [имя] — сохранить и сбросить\n"
+ " !no — отмена"
+ )
+ return [OutgoingMessage(chat_id=event.chat_id, text=text)]
+
+ return handle_reset
+
+
+async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]:
+ try:
+ async with httpx.AsyncClient() as http:
+ resp = await http.post(f"{agent_base_url}/reset", timeout=5.0)
+ if resp.status_code == 404:
+ return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")]
+ return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")]
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
+ logger.warning("reset_endpoint_unreachable", error=str(exc))
+ return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")]
+
+
+def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"):
+ async def handle_context(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ session_name = await prototype_state.get_current_session(event.user_id) or "не загружена"
+ tokens = await prototype_state.get_last_tokens_used(event.user_id)
+ sessions = await prototype_state.list_saved_sessions(event.user_id)
+
+ lines = [
+ "Контекст:",
+ f" Сессия: {session_name}",
+ f" Токены (последний ответ): {tokens}",
+ f" Сохранения ({len(sessions)}):",
+ ]
+ for s in sessions:
+ created = s.get("created_at", "")[:10]
+ lines.append(f" • {s['name']} ({created})")
+ if not sessions:
+ lines.append(" (нет)")
+
+ return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
+
+ return handle_context
+```
+
+3. Edit adapter/matrix/handlers/__init__.py:
+ - Add import at top: `from adapter.matrix.handlers.context_commands import make_handle_save, make_handle_load, make_handle_reset, make_handle_context`
+ - Change signature: `def register_matrix_handlers(dispatcher, client=None, store=None, agent_api=None, prototype_state=None, agent_base_url="http://127.0.0.1:8000") -> None:`
+ - Add at bottom of function before the last line:
+ ```python
+ if agent_api is not None and prototype_state is not None:
+ dispatcher.register(IncomingCommand, "save", make_handle_save(agent_api, store, prototype_state))
+ dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state))
+ dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, agent_base_url))
+ dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state))
+ ```
+
+4. Edit adapter/matrix/bot.py:
+ a. Add imports: `from adapter.matrix.store import get_load_pending, clear_load_pending, get_reset_pending, clear_reset_pending`
+ b. In build_event_dispatcher() and build_runtime(), extract prototype_state from platform if RealPlatformClient, otherwise create new one:
+ In build_runtime() after creating platform:
+ ```python
+ prototype_state = getattr(platform, "_prototype_state", None)
+ agent_api = getattr(platform, "_agent_api", None)
+ agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")
+ ```
+ Pass these to register_matrix_handlers:
+ ```python
+ register_matrix_handlers(dispatcher, client=client, store=store,
+ agent_api=agent_api, prototype_state=prototype_state,
+ agent_base_url=agent_base_url)
+ ```
+ c. In MatrixBot.on_room_message(), before `incoming = from_room_event(...)`:
+ ```python
+ sender = getattr(event, "sender", None)
+ # !load numeric interception
+ load_pending = await get_load_pending(self.runtime.store, sender, room.room_id)
+ if load_pending is not None:
+ text = getattr(event, "body", "").strip()
+ if text.isdigit() or text == "0" or text == "!cancel":
+ outgoing = await self._handle_load_selection(
+ sender, room.room_id, text, load_pending
+ )
+ await self._send_all(room.room_id, outgoing)
+ return
+ ```
+ d. Add _handle_load_selection method to MatrixBot:
+ ```python
+ async def _handle_load_selection(
+ self, user_id: str, room_id: str, text: str, pending: dict
+ ) -> list[OutgoingEvent]:
+ from adapter.matrix.store import clear_load_pending
+ saves = pending.get("saves", [])
+ if text == "0" or text == "!cancel":
+ await clear_load_pending(self.runtime.store, user_id, room_id)
+ return [OutgoingMessage(chat_id=room_id, text="Отменено.")]
+ idx = int(text) - 1
+ if idx < 0 or idx >= len(saves):
+ return [OutgoingMessage(chat_id=room_id, text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.")]
+ name = saves[idx]["name"]
+ await clear_load_pending(self.runtime.store, user_id, room_id)
+ prototype_state = getattr(self.runtime.platform, "_prototype_state", None)
+ if prototype_state is not None:
+ await prototype_state.set_current_session(user_id, name)
+ prompt = f"Load context from /workspace/contexts/{name}.md and use it as background for our conversation. Reply: Loaded: {name}"
+ try:
+ await self.runtime.platform.send_message(user_id, room_id, prompt)
+ except Exception as exc:
+ logger.warning("load_agent_call_failed", error=str(exc))
+ return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")]
+ return [OutgoingMessage(chat_id=room_id, text=f"Загрузка: {name}")]
+ ```
+ e. In MatrixBot.on_room_message(), also add reset_pending check for !yes/!no/!save commands:
+ In the block after load_pending check, before calling dispatcher.dispatch:
+ ```python
+ # !reset pending interception for !yes, !no, !save commands
+ reset_pending = await get_reset_pending(self.runtime.store, sender, room.room_id)
+ if reset_pending is not None:
+ body = getattr(event, "body", "").strip()
+ if body == "!yes" or body.startswith("!save ") or body == "!no":
+ outgoing = await self._handle_reset_selection(sender, room.room_id, body)
+ await self._send_all(room.room_id, outgoing)
+ return
+ ```
+ f. Add _handle_reset_selection method to MatrixBot:
+ ```python
+ async def _handle_reset_selection(
+ self, user_id: str, room_id: str, text: str
+ ) -> list[OutgoingEvent]:
+ from adapter.matrix.store import clear_reset_pending
+ from adapter.matrix.handlers.context_commands import _call_reset_endpoint
+ agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")
+ await clear_reset_pending(self.runtime.store, user_id, room_id)
+ if text == "!no":
+ return [OutgoingMessage(chat_id=room_id, text="Отменено.")]
+ if text.startswith("!save "):
+ name = text[len("!save "):].strip()
+ prototype_state = getattr(self.runtime.platform, "_prototype_state", None)
+ prompt = f"Summarize our conversation and save to /workspace/contexts/{name}.md. Reply only with: Saved: {name}"
+ try:
+ await self.runtime.platform.send_message(user_id, room_id, prompt)
+ if prototype_state:
+ await prototype_state.add_saved_session(user_id, name)
+ except Exception as exc:
+ logger.warning("save_before_reset_failed", error=str(exc))
+ return await _call_reset_endpoint(agent_base_url, room_id)
+ ```
+
+5. Edit sdk/real.py — in stream_message(), after the final yield, add:
+ ```python
+ await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used)
+ ```
+ (This must come after `yield MessageChunk(finished=True, ...)` — use a local variable to store tokens_used before yield, then call set_last_tokens_used after the generator resumes.)
+ Actually: put it before the final yield:
+ ```python
+ await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used)
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=self._agent_api.last_tokens_used,
+ )
+ ```
+
+6. Create tests/adapter/matrix/test_context_commands.py:
+
+```python
+from __future__ import annotations
+
+from typing import AsyncIterator
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from adapter.matrix.bot import MatrixBot, build_runtime
+from core.protocol import IncomingCommand, OutgoingMessage
+from sdk.mock import MockPlatformClient
+from sdk.prototype_state import PrototypeStateStore
+
+
+def make_runtime_with_prototype_state():
+ proto = PrototypeStateStore()
+ platform = MockPlatformClient()
+ # Inject prototype_state into platform so handlers can find it
+ platform._prototype_state = proto
+ runtime = build_runtime(platform=platform)
+ return runtime, proto
+
+
+@pytest.mark.asyncio
+async def test_save_command_auto_name_records_session():
+ proto = PrototypeStateStore()
+ platform = MockPlatformClient()
+ platform._prototype_state = proto
+
+ from adapter.matrix.handlers.context_commands import make_handle_save
+ from core.store import InMemoryStore
+
+ store = InMemoryStore()
+ handler = make_handle_save(agent_api=None, store=store, prototype_state=proto)
+
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!room:example", command="save", args=[])
+
+ class FakePlatform:
+ async def send_message(self, *a, **kw): pass
+
+ result = await handler(event, None, FakePlatform(), None, None)
+ assert any(isinstance(r, OutgoingMessage) and "Сохранение запущено" in r.text for r in result)
+ sessions = await proto.list_saved_sessions("u1")
+ assert len(sessions) == 1
+ assert sessions[0]["name"].startswith("context-")
+
+
+@pytest.mark.asyncio
+async def test_save_command_with_name_uses_given_name():
+ proto = PrototypeStateStore()
+ from adapter.matrix.handlers.context_commands import make_handle_save
+ from core.store import InMemoryStore
+
+ store = InMemoryStore()
+ handler = make_handle_save(agent_api=None, store=store, prototype_state=proto)
+
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="save", args=["my-session"])
+
+ class FakePlatform:
+ async def send_message(self, *a, **kw): pass
+
+ await handler(event, None, FakePlatform(), None, None)
+ sessions = await proto.list_saved_sessions("u1")
+ assert sessions[0]["name"] == "my-session"
+
+
+@pytest.mark.asyncio
+async def test_load_command_shows_numbered_list():
+ proto = PrototypeStateStore()
+ await proto.add_saved_session("u1", "session-A")
+ await proto.add_saved_session("u1", "session-B")
+
+ from adapter.matrix.handlers.context_commands import make_handle_load
+ from core.store import InMemoryStore
+
+ store = InMemoryStore()
+ handler = make_handle_load(store=store, prototype_state=proto)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[])
+
+ result = await handler(event, None, None, None, None)
+ assert len(result) == 1
+ text = result[0].text
+ assert "1." in text and "session-A" in text
+ assert "2." in text and "session-B" in text
+ assert "0" in text
+
+
+@pytest.mark.asyncio
+async def test_load_command_empty_sessions():
+ proto = PrototypeStateStore()
+ from adapter.matrix.handlers.context_commands import make_handle_load
+ from core.store import InMemoryStore
+
+ store = InMemoryStore()
+ handler = make_handle_load(store=store, prototype_state=proto)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[])
+
+ result = await handler(event, None, None, None, None)
+ assert "Нет сохранённых сессий" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_reset_command_shows_dialog():
+ proto = PrototypeStateStore()
+ from adapter.matrix.handlers.context_commands import make_handle_reset
+ from core.store import InMemoryStore
+
+ store = InMemoryStore()
+ handler = make_handle_reset(store=store, agent_base_url="http://127.0.0.1:8000")
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="reset", args=[])
+
+ result = await handler(event, None, None, None, None)
+ text = result[0].text
+ assert "!yes" in text
+ assert "!save" in text
+ assert "!no" in text
+
+
+@pytest.mark.asyncio
+async def test_reset_yes_reports_unavailable_when_endpoint_missing():
+ from adapter.matrix.handlers.context_commands import _call_reset_endpoint
+
+ with patch("httpx.AsyncClient") as MockClient:
+ import httpx
+ MockClient.return_value.__aenter__ = AsyncMock(return_value=MockClient.return_value)
+ MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
+ MockClient.return_value.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
+
+ result = await _call_reset_endpoint("http://127.0.0.1:8000", "!r:e")
+ assert "недоступен" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_context_command_shows_snapshot():
+ proto = PrototypeStateStore()
+ await proto.set_last_tokens_used("u1", 99)
+ await proto.add_saved_session("u1", "my-save")
+
+ from adapter.matrix.handlers.context_commands import make_handle_context
+ from core.store import InMemoryStore
+
+ store = InMemoryStore()
+ handler = make_handle_context(store=store, prototype_state=proto)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="context", args=[])
+
+ result = await handler(event, None, None, None, None)
+ text = result[0].text
+ assert "99" in text
+ assert "my-save" in text
+ assert "не загружена" in text
+```
+
+
+
+ cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -v 2>&1 | tail -20
+
+
+
+ - adapter/matrix/handlers/context_commands.py exists with make_handle_save, make_handle_load, make_handle_reset, make_handle_context, _call_reset_endpoint
+ - register_matrix_handlers() signature accepts agent_api, prototype_state, agent_base_url; registers save/load/reset/context handlers when agent_api is not None
+ - MatrixBot.on_room_message() checks load_pending and reset_pending before dispatcher.dispatch
+ - sdk/real.py calls set_last_tokens_used before final yield
+ - All tests in test_context_commands.py pass
+ - Full test suite still passes: `pytest tests/ -v` exits 0
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Matrix user → command args | !save [name] arg is user-controlled; used in file paths |
+| bot → agent (save/load prompts) | Prompt text contains user-supplied name |
+| bot → POST /reset endpoint | HTTP call to AGENT_BASE_URL (internal service) |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-02-01 | Tampering | !save name arg used in /workspace/contexts/{name}.md path | mitigate | Sanitize name: only allow [a-zA-Z0-9_-] characters; reject path traversal attempts (names containing "/" or "..") |
+| T-04-02-02 | Information Disclosure | !context exposes tokens_used and session names | accept | Single-user prototype; data is the user's own |
+| T-04-02-03 | Denial of Service | !load numeric interception could loop if load_pending never clears | mitigate | clear_load_pending on selection (any) or disconnect; pending data is volatile in-memory |
+| T-04-02-04 | Spoofing | !yes intercepted by reset_pending could conflict with pending_confirm | mitigate | Reset_pending check in on_room_message before dispatcher — takes priority; documented in code comment |
+| T-04-02-05 | Tampering | POST /reset to AGENT_BASE_URL | accept | Internal service URL from env; timeout=5.0 prevents hanging |
+
+
+
+Run full suite after both tasks:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30
+```
+
+Grep checks:
+```bash
+# Handlers registered
+grep "save\|load\|reset\|context" adapter/matrix/handlers/__init__.py
+
+# Numeric interception in bot
+grep "get_load_pending\|_handle_load_selection" adapter/matrix/bot.py
+
+# tokens tracking in real.py
+grep "set_last_tokens_used" sdk/real.py
+
+# context_commands module
+ls adapter/matrix/handlers/context_commands.py
+```
+
+
+
+- `pytest tests/adapter/matrix/test_context_commands.py -v` exits 0 with 7+ tests passing
+- `pytest tests/platform/test_prototype_state.py -v` exits 0 (including 4 new tests)
+- `pytest tests/ -v` exits 0
+- !save, !load, !reset, !context all registered in register_matrix_handlers
+- load_pending and reset_pending helpers exist in adapter/matrix/store.py
+- MatrixBot.on_room_message contains numeric interception for !load
+
+
+
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
new file mode 100644
index 0000000..e6ccc76
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
@@ -0,0 +1,40 @@
+# Phase 04 Plan 02: Matrix Context Commands Summary
+
+## Outcome
+
+Added Matrix context management for `!save`, `!load`, `!reset`, and `!context`, plus
+pending-state interception in the Matrix bot and prototype-state tracking for saved
+sessions, current session, and last token usage.
+
+## Commits
+
+- `2720ee2` `feat(04-02): extend prototype and matrix pending state`
+- `b52fdc4` `feat(04-02): add matrix context management commands`
+
+## Verification
+
+- `pytest tests/platform/test_prototype_state.py -q`
+- `pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -q`
+- `pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_confirm.py -q`
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+1. `[Rule 2 - Critical functionality]` Sanitized save names before using them in save/load prompts.
+ This rejects names outside `[A-Za-z0-9_-]` and prevents path-like input from flowing into `/workspace/contexts/{name}.md`.
+
+2. `[Rule 2 - Critical functionality]` Cleared the active current-session marker on reset.
+ Without this, `!context` could report a stale loaded session after `!reset`.
+
+## Files Changed
+
+- `sdk/prototype_state.py`
+- `adapter/matrix/store.py`
+- `adapter/matrix/handlers/__init__.py`
+- `adapter/matrix/handlers/context_commands.py`
+- `adapter/matrix/bot.py`
+- `tests/adapter/matrix/test_context_commands.py`
+- `tests/platform/test_prototype_state.py`
+
+## Self-Check: PASSED
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md
new file mode 100644
index 0000000..7c6781b
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md
@@ -0,0 +1,193 @@
+---
+phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+plan: 03
+type: execute
+wave: 2
+depends_on:
+ - 04-01-PLAN.md
+files_modified:
+ - Dockerfile
+ - docker-compose.yml
+ - .env.example
+autonomous: true
+requirements:
+ - Dockerfile for Matrix bot
+ - docker-compose.yml with matrix-bot service
+ - .env.example updated with AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND
+
+must_haves:
+ truths:
+ - "Dockerfile builds successfully with python:3.11-slim base"
+ - "lambda_agent_api installed in container despite Python version constraint"
+ - "PYTHONPATH=/app set so adapter/matrix/bot.py is runnable as module"
+ - "docker-compose.yml defines matrix-bot service with env_file: .env"
+ - ".env.example contains AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND=real"
+ - "CMD runs python -m adapter.matrix.bot"
+ artifacts:
+ - path: "Dockerfile"
+ provides: "Matrix bot container image"
+ contains: "python:3.11-slim"
+ - path: "docker-compose.yml"
+ provides: "Service definition for matrix-bot"
+ contains: "matrix-bot"
+ - path: ".env.example"
+ provides: "Updated env template"
+ contains: "AGENT_BASE_URL"
+ key_links:
+ - from: "Dockerfile"
+ to: "external/platform-agent_api"
+ via: "COPY + pip install with --ignore-requires-python"
+ pattern: "ignore-requires-python"
+---
+
+
+Package the Matrix bot in a Docker container. Create Dockerfile using python:3.11-slim,
+install lambda_agent_api from the local external/ directory (bypassing the Python 3.14
+version constraint), and define a docker-compose.yml for running the matrix-bot service.
+Update .env.example with new variables.
+
+Purpose: Enable reproducible MVP deployment of the Matrix bot in a container alongside
+the separately-run platform-agent.
+
+Output: Dockerfile, docker-compose.yml, updated .env.example.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md
+
+
+
+
+
+ Task 1: Create Dockerfile and docker-compose.yml
+
+
+ - .env.example (full file — adding new vars)
+ - external/platform-agent_api/lambda_agent_api/ (ls — verify files to copy)
+ - pyproject.toml (verify uv is the package manager used)
+
+
+ Dockerfile, docker-compose.yml, .env.example
+
+
+1. Check if pyproject.toml uses uv or pip. The project uses `uv sync` per CLAUDE.md. However, in the Docker container we can use pip for simplicity since uv's lockfile-based install may need the lockfile present. Use pip for the base install of surfaces-bot deps, and install lambda_agent_api separately.
+
+ Actually: the project uses uv. Use uv in Docker to be consistent:
+ - Install uv via pip (pip install uv)
+ - Run uv sync to install project deps
+ - Install lambda_agent_api with pip --ignore-requires-python
+
+2. Create Dockerfile:
+
+```dockerfile
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# Install uv
+RUN pip install --no-cache-dir uv
+
+# Copy dependency manifests first for layer caching
+COPY pyproject.toml uv.lock* ./
+
+# Install project dependencies via uv (no project install yet, just deps)
+RUN uv sync --no-install-project --frozen 2>/dev/null || uv sync --no-install-project
+
+# Copy project source
+COPY . .
+
+# Install the project itself
+RUN uv sync --frozen 2>/dev/null || uv sync
+
+# Install lambda_agent_api, bypassing Python version constraint
+RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api
+
+ENV PYTHONPATH=/app
+ENV PYTHONUNBUFFERED=1
+
+CMD ["python", "-m", "adapter.matrix.bot"]
+```
+
+3. Create docker-compose.yml:
+
+```yaml
+services:
+ matrix-bot:
+ build: .
+ env_file: .env
+ restart: unless-stopped
+ # platform-agent runs separately — not included in this compose file
+```
+
+4. Read current .env.example, then append new variables. Current file likely has MATRIX_* vars. Add:
+ - AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/
+ - AGENT_BASE_URL=http://127.0.0.1:8000
+ - MATRIX_PLATFORM_BACKEND=real
+
+ Read .env.example first to see what's there, then write the full updated file.
+
+
+
+ - `grep "python:3.11-slim" Dockerfile` returns a match
+ - `grep "ignore-requires-python" Dockerfile` returns a match (lambda_agent_api install)
+ - `grep "PYTHONPATH=/app" Dockerfile` returns a match
+ - `grep "adapter.matrix.bot" Dockerfile` returns a match (CMD)
+ - `grep "matrix-bot" docker-compose.yml` returns a match
+ - `grep "env_file" docker-compose.yml` returns a match
+ - `grep "AGENT_BASE_URL" .env.example` returns a match
+ - `grep "MATRIX_PLATFORM_BACKEND" .env.example` returns a match
+ - Dockerfile exists with python:3.11-slim, uv install, lambda_agent_api pip install --ignore-requires-python, PYTHONPATH=/app, CMD python -m adapter.matrix.bot
+ - docker-compose.yml exists with matrix-bot service, env_file: .env, restart: unless-stopped
+ - .env.example contains AGENT_WS_URL, AGENT_BASE_URL, MATRIX_PLATFORM_BACKEND=real
+
+
+
+ grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example && echo "All checks passed"
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| container → host env | .env file mounts secrets into container |
+| container → platform-agent | Network call to AGENT_WS_URL / AGENT_BASE_URL |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-03-01 | Information Disclosure | .env file with secrets mounted in container | mitigate | .env in .gitignore; .env.example committed with placeholder values only, never real secrets |
+| T-04-03-02 | Tampering | lambda_agent_api installed from local path via --ignore-requires-python | accept | Local package under version control; no external supply chain risk |
+| T-04-03-03 | Denial of Service | restart: unless-stopped could loop on crash | accept | Expected behavior; operator can `docker compose stop` |
+
+
+
+```bash
+# Verify files exist and contain expected content
+grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile
+grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile
+grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example
+grep "matrix-bot" /Users/a/MAI/sem2/lambda/surfaces-bot/docker-compose.yml
+```
+
+
+
+- Dockerfile, docker-compose.yml, .env.example all exist in project root
+- Dockerfile builds without errors when platform-agent_api dir is present (docker build . exits 0)
+- .env.example contains AGENT_BASE_URL, AGENT_WS_URL, MATRIX_PLATFORM_BACKEND
+- docker-compose.yml service named matrix-bot uses env_file: .env
+
+
+
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md
new file mode 100644
index 0000000..38957dd
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md
@@ -0,0 +1,106 @@
+---
+phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+plan: 03
+subsystem: infra
+tags: [docker, docker-compose, matrix, uv, lambda-agent-api]
+requires:
+ - phase: 04-01
+ provides: Matrix MVP runtime and environment model
+provides:
+ - Matrix bot Docker image definition
+ - Single-service docker-compose setup for matrix-bot
+ - Env template entries for Agent API base URLs and real backend selection
+affects: [deployment, matrix, local-dev]
+tech-stack:
+ added: [Dockerfile, docker-compose]
+ patterns: [uv-managed container install with system Python runtime, local path install for lambda_agent_api]
+key-files:
+ created: [Dockerfile, docker-compose.yml]
+ modified: [.env.example]
+key-decisions:
+ - "Kept compose scoped to matrix-bot only; platform-agent remains external to this stack."
+ - "Set UV_PROJECT_ENVIRONMENT=/usr/local so uv-installed dependencies are available to CMD [\"python\", \"-m\", \"adapter.matrix.bot\"]."
+patterns-established:
+ - "Install project dependencies with uv inside the container, then install lambda_agent_api from external/platform-agent_api via pip --ignore-requires-python."
+requirements-completed: [Dockerfile for Matrix bot, docker-compose.yml with matrix-bot service, .env.example updated with AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND]
+duration: 6min
+completed: 2026-04-17
+---
+
+# Phase 4 Plan 03: Matrix Bot Containerization Summary
+
+**Python 3.11 Matrix bot container with uv-managed app dependencies, local lambda_agent_api install bypass, and a single-service compose entrypoint**
+
+## Performance
+
+- **Duration:** 6 min
+- **Started:** 2026-04-17T13:01:00Z
+- **Completed:** 2026-04-17T13:07:04Z
+- **Tasks:** 1
+- **Files modified:** 4
+
+## Accomplishments
+
+- Added a root `Dockerfile` for the Matrix bot using `python:3.11-slim`.
+- Added `docker-compose.yml` with a single `matrix-bot` service using `env_file: .env`.
+- Extended `.env.example` with `AGENT_WS_URL`, `AGENT_BASE_URL`, and `MATRIX_PLATFORM_BACKEND=real`.
+
+## Files Created/Modified
+
+- `Dockerfile` - Builds the Matrix bot image, installs project dependencies with `uv`, and installs `lambda_agent_api` from the local `external/` tree.
+- `docker-compose.yml` - Defines the `matrix-bot` service with restart policy and `.env` loading.
+- `.env.example` - Documents the agent WebSocket URL, agent HTTP base URL, and real backend selector.
+
+## Decisions Made
+
+- Kept the compose scope limited to the Matrix bot, matching the phase boundary and excluding platform services.
+- Added `UV_PROJECT_ENVIRONMENT=/usr/local` as a correctness fix so `uv sync` installs are visible to the final `python` runtime.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 2 - Missing Critical] Ensured uv installs are available to the container runtime**
+- **Found during:** Task 1 (Create Dockerfile and docker-compose.yml)
+- **Issue:** The plan sketch used `uv sync` plus `CMD ["python", ...]`; by default, `uv sync` would install into a virtual environment that system `python` would not use.
+- **Fix:** Set `UV_PROJECT_ENVIRONMENT=/usr/local` in the Dockerfile before running `uv sync`.
+- **Files modified:** `Dockerfile`
+- **Verification:** Required grep checks passed and the generated compose config remained valid.
+
+---
+
+**Total deviations:** 1 auto-fixed (1 missing critical)
+**Impact on plan:** Narrow correctness fix only. No scope expansion.
+
+## Issues Encountered
+
+- `docker compose config` resolved values from the local `.env`, so verification was kept to config rendering and grep-style checks rather than a full image build.
+
+## User Setup Required
+
+- Create `.env` from `.env.example` with real Matrix and agent values before running `docker compose up`.
+
+## Next Phase Readiness
+
+- Matrix bot container packaging is in place and ready for operator-supplied secrets plus an external platform-agent deployment.
+- No code changes were made outside the allowed containerization files.
+
+## Verification
+
+- `grep 'python:3.11-slim' Dockerfile`
+- `grep 'ignore-requires-python' Dockerfile`
+- `grep 'PYTHONPATH=/app' Dockerfile`
+- `grep 'adapter.matrix.bot' Dockerfile`
+- `grep 'matrix-bot' docker-compose.yml`
+- `grep 'env_file' docker-compose.yml`
+- `grep 'AGENT_BASE_URL' .env.example`
+- `grep 'AGENT_WS_URL' .env.example`
+- `grep 'MATRIX_PLATFORM_BACKEND' .env.example`
+- `docker compose -f docker-compose.yml config`
+
+## Self-Check: PASSED
+
+- Found `Dockerfile`
+- Found `docker-compose.yml`
+- Found updated `.env.example`
+- Found `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md`
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md
new file mode 100644
index 0000000..5637a34
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md
@@ -0,0 +1,136 @@
+# Phase 4: Matrix MVP — Agent Context + Context Management — Context
+
+**Gathered:** 2026-04-16
+**Status:** Ready for planning
+**Source:** Conversation context (2026-04-16 design session)
+
+
+## Phase Boundary
+
+Привести Matrix-бот к рабочему состоянию для MVP-деплоя в контейнер:
+- Убрать наш кастомный `AgentSessionClient` и thread_id патч из `platform-agent`, перейти на актуальный origin/main платформы с `AgentApi` из `lambda_agent_api`
+- Добавить 4 команды управления контекстом агента: `!save`, `!load`, `!reset`, `!context`
+- Упаковать Matrix-бот в Docker-контейнер
+
+НЕ входит в фазу:
+- Изменения в platform-agent (это задача команды платформы)
+- Telegram адаптер
+- E2EE
+- Skills system (ждём платформу)
+
+
+
+
+## Implementation Decisions
+
+### Архитектура платформы (locked)
+
+- **Один контейнер = один чат**: `AgentService` с `thread_id = "default"` — намеренная архитектура. Изоляция на уровне контейнеров, не thread_id. Не менять.
+- **Убрать thread_id патч**: наш коммит `1dca2c1` в `external/platform-agent` удаляем. Переходим на `origin/main` platform-agent.
+- **Удалить `build_thread_key`**: функция больше не нужна. Убрать из `sdk/agent_session.py` и `sdk/real.py`.
+- **Заменить `AgentSessionClient` на `AgentApi`**: использовать `AgentApi` из `external/platform-agent_api/lambda_agent_api/agent_api.py`. Он уже правильно обрабатывает все event-типы (неизвестные → `logger.warning`, без краша).
+
+### !save (locked)
+
+- Синтаксис: `!save` (автоимя по дате/времени) или `!save [имя]`
+- Механизм: Matrix-бот посылает агенту текстовое сообщение "Summarize our conversation and save to /workspace/contexts/[name].md. Reply only with: Saved: [name]"
+- Имена сохранений хранятся в `PrototypeStateStore` (список для `!load`)
+- Агент сам пишет файл через свои инструменты (`write_file`)
+
+### !load (locked)
+
+- `!load` без аргументов → бот показывает нумерованный список сохранений
+- Пользователь вводит **число** (1, 2, 3...) для выбора
+- Выход из состояния: `0` или `!cancel`
+- После выбора: бот посылает агенту "Load context from /workspace/contexts/[name].md and use it as background for our conversation. Reply: Loaded: [name]"
+- Состояние ожидания выбора хранится в Matrix store (аналогично pending_confirm)
+
+### !reset (locked)
+
+- Показывает confirmation-диалог:
+ ```
+ Сбросить контекст агента? Выбери:
+ !yes — сбросить
+ !save [имя] — сохранить и сбросить
+ !no — отмена
+ ```
+- `!yes` → вызвать `POST {AGENT_BASE_URL}/reset` (resets AgentService singleton)
+- `!save имя` → сначала выполняется логика !save, затем POST /reset
+- `!no` → отмена
+- Fallback если `/reset` endpoint недоступен (404): вернуть пользователю "Reset endpoint недоступен. Обратитесь к администратору."
+- `AGENT_BASE_URL` — новая env переменная (HTTP base URL агента, отдельно от `AGENT_WS_URL`)
+
+### !context (locked)
+
+- Показывает: имя текущей сессии (если загружали через `!load`), токены из последнего ответа (`tokens_used` из `MsgEventEnd`), список сохранений (имена + даты)
+- Не делает никаких вызовов к агенту
+
+### Dockerfile + docker-compose (locked)
+
+- `Dockerfile` для Matrix-бота (`adapter/matrix/bot.py`)
+- `docker-compose.yml` с сервисом `matrix-bot`
+- Env переменные через `.env` файл
+- Platform-agent запускается отдельно (не входит в compose этой фазы)
+
+### Claude's Discretion
+
+- Структура хранения saved sessions в PrototypeStateStore (dict name→timestamp)
+- Формат автоимени для !save без аргументов
+- HTTP клиент для POST /reset (aiohttp или httpx)
+- Точный формат промптов к агенту для save/load
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Platform клиент (заменяем)
+- `sdk/agent_session.py` — текущий AgentSessionClient, УДАЛЯЕМ/ЗАМЕНЯЕМ
+- `sdk/real.py` — RealPlatformClient, обновляем под AgentApi
+- `external/platform-agent_api/lambda_agent_api/agent_api.py` — новый клиент AgentApi
+- `external/platform-agent_api/lambda_agent_api/server.py` — типы сообщений (MsgStatus, MsgEventTextChunk, MsgEventEnd, etc.)
+- `external/platform-agent_api/lambda_agent_api/client.py` — MsgUserMessage
+
+### Matrix адаптер (расширяем)
+- `adapter/matrix/bot.py` — точка входа, MatrixBot, build_runtime
+- `adapter/matrix/handlers/` — существующие обработчики команд
+- `adapter/matrix/store.py` — get_room_meta, set_pending_confirm (паттерн для !load state)
+- `sdk/prototype_state.py` — PrototypeStateStore, расширяем для saved sessions
+
+### Состояние платформы
+- `.planning/threads/matrix-dev-prototype-agent-platform-state.md` — исследование от 2026-04-14
+
+### Существующая архитектура команд
+- `core/protocol.py` — IncomingCommand, OutgoingMessage, OutgoingUI
+- `core/handlers/` — паттерны регистрации обработчиков
+
+
+
+
+## Specific Ideas
+
+- `AgentApi` требует явного `connect()` при старте и `close()` при завершении — lifecycle нужно встроить в `MatrixBot`
+- `AgentApi.send_message()` — AsyncIterator, возвращает `MsgEventTextChunk` чанки и `MsgEventEnd`
+- Для `!load` состояние "ожидаем число" хранить по ключу `load_pending:{matrix_user_id}:{room_id}` в store (аналог pending_confirm)
+- `AGENT_BASE_URL` — HTTP URL, например `http://127.0.0.1:8000`; `AGENT_WS_URL` = `ws://127.0.0.1:8000/agent_ws/`
+- platform-agent origin/main: `POST /reset` эндпоинта нет — это нужно запросить у команды платформы. До тех пор `!reset` возвращает "Reset endpoint недоступен"
+
+
+
+
+## Deferred Ideas
+
+- Замена `PrototypeStateStore` на реальный control-plane из platform-master (Phase 3)
+- Skills интеграция через SkillsMiddleware (ждём платформу)
+- E2EE для Matrix
+- `!reset` через docker restart (заменяется на /reset endpoint когда платформа добавит)
+- Суммаризация контекста (агент сам решает как писать в файл)
+
+
+
+---
+
+*Phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma*
+*Context gathered: 2026-04-16 via conversation design session*
diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md
new file mode 100644
index 0000000..4cf1b60
--- /dev/null
+++ b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md
@@ -0,0 +1,546 @@
+# Phase 4: Matrix MVP — Shared Agent Context + Context Management — Research
+
+**Researched:** 2026-04-16
+**Domain:** Matrix bot, AgentApi WebSocket client, context management commands, Docker packaging
+**Confidence:** HIGH (all findings verified against actual source files in this repo)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**Архитектура платформы:**
+- Один контейнер = один чат: `AgentService` с `thread_id = "default"` — намеренная архитектура. Не менять.
+- Убрать thread_id патч: наш коммит `1dca2c1` в `external/platform-agent` удаляем. Переходим на `origin/main` platform-agent.
+- Удалить `build_thread_key`: функция больше не нужна. Убрать из `sdk/agent_session.py` и `sdk/real.py`.
+- Заменить `AgentSessionClient` на `AgentApi`: использовать `AgentApi` из `external/platform-agent_api/lambda_agent_api/agent_api.py`.
+
+**!save:** Синтаксис `!save` (автоимя) или `!save [имя]`. Механизм: посылаем агенту текстовое сообщение. Имена сохранений хранятся в `PrototypeStateStore`.
+
+**!load:** `!load` без аргументов → нумерованный список. Пользователь вводит число. Выход: `0` или `!cancel`. После выбора — посылаем агенту текстовое сообщение. Состояние ожидания в Matrix store.
+
+**!reset:** Confirmation-диалог с `!yes`/`!save имя`/`!no`. `!yes` → `POST {AGENT_BASE_URL}/reset`. Fallback если 404: сообщение пользователю.
+
+**!context:** Показывает имя сессии, токены, список сохранений. Без вызовов агента.
+
+**Dockerfile + docker-compose:** Для Matrix-бота. Env через `.env`. Platform-agent — отдельно.
+
+### Claude's Discretion
+
+- Структура хранения saved sessions в PrototypeStateStore (dict name→timestamp)
+- Формат автоимени для !save без аргументов
+- HTTP клиент для POST /reset (aiohttp или httpx)
+- Точный формат промптов к агенту для save/load
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Замена `PrototypeStateStore` на реальный control-plane из platform-master
+- Skills интеграция через SkillsMiddleware
+- E2EE для Matrix
+- `!reset` через docker restart
+- Суммаризация контекста
+
+
+---
+
+## Summary
+
+Phase 4 replaces the custom `AgentSessionClient` with the production `AgentApi` from `lambda_agent_api`, adds four context management commands to the Matrix bot, and packages it in Docker. All findings are verified directly against source files.
+
+**Primary recommendation:** Wire `AgentApi` as a persistent connection in `MatrixBot.__init__` (connect on start, close in finally block of `main()`). Expose it through `RealPlatformClient`. The four commands follow the existing handler registration pattern in `adapter/matrix/handlers/__init__.py`.
+
+The `platform-agent` at `origin/main` already works with `AgentApi` — it does NOT require `thread_id` query param. Our local patch (`1dca2c1`) must be discarded and `external/platform-agent` reset to `origin/main`.
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+- **Tech stack:** matrix-nio for Matrix — do not change without discussion
+- **Platform client:** connected only via `sdk/interface.py` Protocol — core/ and adapters untouched when swapping implementation
+- **No E2EE** — matrix-nio without python-olm
+- **Hotfixes < 20 lines** → Claude Code directly; implementation → Codex via GSD
+- **MATRIX_PLATFORM_BACKEND env var** controls mock vs real
+
+---
+
+## Standard Stack
+
+### Core (verified)
+| Library | Version | Purpose | Source |
+|---------|---------|---------|--------|
+| `lambda_agent_api` | local (external/platform-agent_api) | AgentApi WebSocket client | [VERIFIED: file read] |
+| `aiohttp` | >=3.9 (surfaces-bot), >=3.13.4 (agent_api) | WebSocket transport inside AgentApi | [VERIFIED: pyproject.toml] |
+| `pydantic` | >=2.5 | Message serialization (MsgUserMessage, MsgEventEnd, etc.) | [VERIFIED: server.py/client.py] |
+| `httpx` | >=0.27 | HTTP client for POST /reset (already in deps) | [VERIFIED: pyproject.toml] |
+| `structlog` | >=24.1 | Logging (existing pattern) | [VERIFIED: pyproject.toml] |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `aiohttp` | already a dep | Alternative HTTP for POST /reset | Could use instead of httpx — both available |
+
+**Installation:** No new packages needed. `lambda_agent_api` is installed as a local path package (currently accessed via `sys.path` injection in tests; for production use, add to pyproject.toml as path dep or install via `pip install -e external/platform-agent_api`).
+
+**Critical:** `lambda_agent_api` requires Python >=3.14 per its own `pyproject.toml`. The surfaces-bot requires Python >=3.11. [VERIFIED: pyproject.toml of both]. This is a **version mismatch** — see Pitfalls.
+
+---
+
+## Architecture Patterns
+
+### AgentApi Constructor (verified)
+
+```python
+# Source: external/platform-agent_api/lambda_agent_api/agent_api.py
+AgentApi(
+ agent_id: str, # arbitrary string ID, used in logs
+ url: str, # WebSocket URL, e.g. "ws://127.0.0.1:8000/agent_ws/"
+ callback: Optional[Callable[[ServerMessage], None]] = None, # for orphaned msgs
+ on_disconnect: Optional[Callable[['AgentApi'], None]] = None # called on WS close
+)
+```
+
+### AgentApi Lifecycle (verified)
+
+```python
+# Source: external/platform-agent_api/lambda_agent_api/agent_api.py
+agent = AgentApi(agent_id="matrix-bot", url=ws_url)
+await agent.connect() # opens WS, waits for MsgStatus, starts _listen() task
+# ... use agent ...
+await agent.close() # cancels _listen task, closes WS and session
+```
+
+`connect()` blocks until `MsgStatus` is received from server (5s timeout). After `connect()`, a background `_listen()` asyncio task runs continuously, routing server messages to an internal `asyncio.Queue`.
+
+### AgentApi.send_message() semantics (verified)
+
+```python
+# Source: external/platform-agent_api/lambda_agent_api/agent_api.py, line 134
+async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]:
+```
+
+- `AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd]` — **but** the generator `yield`s only `MsgEventTextChunk` chunks; it `break`s (stops) on `MsgEventEnd` without yielding it.
+- `MsgEventEnd` carries `tokens_used: int` — to capture this, the caller must intercept the queue or handle `MsgEventEnd` in the `_listen` loop. **Currently `send_message` discards `tokens_used`.** This affects `!context` which needs tokens.
+
+**Resolution:** In `RealPlatformClient.stream_message()`, after iterating through `send_message()`, `tokens_used` won't be directly available. Options:
+1. Store `tokens_used` in a shared attribute after each response (add `self._last_tokens_used` to `AgentApi` or a wrapper).
+2. Use the `callback` parameter to capture `MsgEventEnd` events from the `_listen` loop.
+
+[ASSUMED] The simplest approach: wrap `AgentApi` in a thin `AgentApiAdapter` class that intercepts `_listen` output and exposes `last_tokens_used`. Or: store tokens in `PrototypeStateStore` after each message.
+
+### AgentApi concurrency constraint (verified)
+
+`AgentApi._request_lock` prevents parallel `send_message()` calls — second call raises `AgentBusyException`. In the single-user Matrix prototype this is acceptable. The bot must not dispatch two messages concurrently to the same agent.
+
+### Wiring AgentApi into MatrixBot (integration pattern)
+
+The `AgentApi` must be a persistent connection (not per-message connect/disconnect) because:
+1. `_listen()` task runs in background and routes server push events.
+2. Per-message connect/disconnect would recreate the aiohttp session each time and discard LangGraph thread state.
+
+**Recommended wiring:**
+
+```python
+# adapter/matrix/bot.py — main() function
+agent_api = AgentApi(agent_id="matrix-bot", url=ws_url)
+await agent_api.connect()
+runtime = build_runtime(store=SQLiteStore(db_path), client=client, agent_api=agent_api)
+try:
+ await client.sync_forever(timeout=30000, since=since_token)
+finally:
+ await client.close()
+ await agent_api.close()
+```
+
+`_build_platform_from_env()` currently instantiates everything synchronously. It must be refactored to `async` or split: construct `AgentApi` synchronously, call `connect()` in `main()` before starting sync loop.
+
+### RealPlatformClient updates
+
+`RealPlatformClient` currently imports `AgentSessionClient` and calls `build_thread_key`. Both are removed. The updated class:
+
+```python
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_api: AgentApi, # replaces agent_sessions: AgentSessionClient
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+```
+
+`send_message()` and `stream_message()` call `agent_api.send_message(text)` directly — no `thread_key` needed.
+
+### platform-agent origin/main: what changes (verified)
+
+Our patch `1dca2c1` added `thread_id` query param handling to `external/platform-agent/src/api/external.py`. On `origin/main`, the `process_message()` function does NOT use `thread_id` — it calls `agent_service.astream(msg.text)` without `thread_id`. The WS URL becomes simply `ws://host:port/agent_ws/` — no query params.
+
+### Existing command registration pattern (verified)
+
+```python
+# adapter/matrix/handlers/__init__.py — register_matrix_handlers()
+dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
+dispatcher.register(IncomingCommand, "settings", handle_settings)
+dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store))
+```
+
+Handler signature (all existing handlers follow this):
+```python
+async def handle_X(
+ event: IncomingCommand,
+ auth_mgr,
+ platform,
+ chat_mgr,
+ settings_mgr,
+) -> list[OutgoingEvent]:
+```
+
+New context commands need access to `agent_api` (for `!save`, `!load`) and `store` (for `!context`, `!load` pending state). Pattern: use `make_handle_X(agent_api, store)` closures — same as `make_handle_new_chat(client, store)`.
+
+### !load pending state pattern (verified)
+
+Existing `PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"` in `adapter/matrix/store.py`.
+
+New key for load pending state:
+```python
+LOAD_PENDING_PREFIX = "matrix_load_pending:"
+
+def _load_pending_key(user_id: str, room_id: str) -> str:
+ return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}"
+```
+
+Stored data structure:
+```python
+{
+ "saves": [{"name": "my-save", "ts": "2026-04-16T12:00:00Z"}, ...],
+ "display": "1. my-save (2026-04-16)\n2. other..."
+}
+```
+
+The numeric input `1`, `2`, etc. is intercepted in `MatrixBot.on_room_message()` BEFORE dispatching as `IncomingMessage` — check if `load_pending` exists for this user+room, resolve to save name, dispatch the load command internally.
+
+**Alternative (recommended):** Handle numeric input in the `IncomingMessage` handler via a pre-dispatch interceptor, or register a special numeric-input check in the dispatcher for messages that are pure integers.
+
+### !reset confirmation dialog pattern
+
+!reset reuses the `OutgoingUI` + `pending_confirm` mechanism or a simpler custom state. Since the dialog options are `!yes`, `!save имя`, `!no` (not just yes/no), it cannot reuse `pending_confirm` directly without extension.
+
+Simplest approach: store `reset_pending:{user_id}:{room_id}` key (boolean) and check for `!yes`/`!no`/`!save` commands from the `IncomingCommand` dispatcher when reset_pending is set.
+
+### saved sessions storage in PrototypeStateStore
+
+New dict attribute on `PrototypeStateStore`:
+```python
+self._saved_sessions: dict[str, list[dict]] = {}
+# Key: matrix_user_id
+# Value: [{"name": "my-save", "created_at": "2026-04-16T12:00:00Z"}, ...]
+```
+
+Methods to add:
+```python
+async def add_saved_session(self, user_id: str, name: str) -> None: ...
+async def list_saved_sessions(self, user_id: str) -> list[dict]: ...
+```
+
+### !context tokens_used tracking
+
+`MsgEventEnd.tokens_used: int` is available from `server.py`. Since `AgentApi.send_message()` drops it, the planner must decide how to surface it. Recommended: store in `PrototypeStateStore` as `_last_tokens_used: dict[str, int]` keyed by user_id, updated after each successful agent response in `RealPlatformClient`.
+
+### Prompts for !save / !load (Claude's Discretion)
+
+```python
+# !save
+SAVE_PROMPT = (
+ "Summarize our conversation and save to /workspace/contexts/{name}.md. "
+ "Reply only with: Saved: {name}"
+)
+
+# !load
+LOAD_PROMPT = (
+ "Load context from /workspace/contexts/{name}.md and use it as background "
+ "for our conversation. Reply: Loaded: {name}"
+)
+```
+
+Auto-name format (Claude's Discretion): `context-{YYYYMMDD-HHMMSS}` (UTC, no spaces, no special chars, safe as filename).
+
+### POST /reset endpoint
+
+Confirmed absent in `origin/main` platform-agent. Only endpoint is `GET /agent_ws/` (WebSocket). The `main.py` has no HTTP routes beyond what FastAPI provides by default (`/docs`, `/openapi.json`).
+
+`!reset` with `!yes` → `POST {AGENT_BASE_URL}/reset` → expect 404 → return "Reset endpoint недоступен. Обратитесь к администратору."
+
+HTTP client for this: **httpx** (already in `pyproject.toml`):
+```python
+import httpx
+async with httpx.AsyncClient() as client:
+ response = await client.post(f"{agent_base_url}/reset", timeout=5.0)
+ if response.status_code == 404:
+ return [OutgoingMessage(chat_id=..., text="Reset endpoint недоступен...")]
+```
+
+### Dockerfile
+
+```dockerfile
+FROM python:3.11-slim
+WORKDIR /app
+COPY pyproject.toml .
+RUN pip install -e .
+COPY . .
+ENV PYTHONUNBUFFERED=1
+CMD ["python", "-m", "adapter.matrix.bot"]
+```
+
+`lambda_agent_api` must be installed in the container. Options:
+1. `COPY external/platform-agent_api /app/external/platform-agent_api` + `pip install -e /app/external/platform-agent_api`
+2. Include `lambda_agent_api` package directly in `surfaces-bot` package (copy source files)
+
+Option 1 is cleaner.
+
+### docker-compose.yml structure
+
+```yaml
+services:
+ matrix-bot:
+ build: .
+ env_file: .env
+ restart: unless-stopped
+```
+
+Platform-agent runs separately — not in this compose file.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| WebSocket lifecycle with reconnect | Custom WS manager | `AgentApi` from `lambda_agent_api` | Already handles connect/close/listen loop, error routing, queue management |
+| Message deserialization | Custom JSON parsing | `ServerMessage.validate_json()` (Pydantic TypeAdapter) | Discriminated union handles all message types |
+| HTTP async client | `aiohttp.ClientSession` directly | `httpx.AsyncClient` | Already in deps, cleaner API for one-shot POST |
+| Concurrent request guard | Custom lock | `AgentApi._request_lock` | Already implemented, raises `AgentBusyException` |
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: lambda_agent_api Python version mismatch
+
+**What goes wrong:** `lambda_agent_api/pyproject.toml` declares `requires-python = ">=3.14"`. The surfaces-bot runs on Python 3.11+. If `pip install -e external/platform-agent_api` is run with Python 3.11 it may fail or emit warnings.
+
+**Why it happens:** The `lambda_agent_api` was developed under Python 3.14 (seen in `.venv` path: `python3.14`). The code itself uses no 3.14-specific syntax — it is pure aiohttp + pydantic which run on 3.11.
+
+**How to avoid:** Change `requires-python = ">=3.11"` in `external/platform-agent_api/pyproject.toml` before building the Docker image, or install with `--ignore-requires-python`. Alternatively, copy the three source files directly into the surfaces-bot package.
+
+**Warning signs:** `pip install` failure with "requires Python >=3.14".
+
+### Pitfall 2: AgentApi.send_message() drops MsgEventEnd (tokens_used lost)
+
+**What goes wrong:** The generator yields only `MsgEventTextChunk` objects and breaks on `MsgEventEnd` without yielding it. Any downstream code that tries to get `tokens_used` from the iterator gets nothing.
+
+**Why it happens:** The generator `break`s on `MsgEventEnd` (line 172 of agent_api.py) without yielding it. This is intentional for streaming UX but loses token info.
+
+**How to avoid:** Before streaming, set `self._last_tokens_used = 0`. In `_listen()`, `MsgEventEnd` is put into `_current_queue` (line 241). The `send_message()` generator reads from that queue but does `break` — the `MsgEventEnd` object is consumed but not returned to caller. The only way to capture it is to subclass `AgentApi` or read from `_current_queue` directly before the break.
+
+**Practical fix:** Add `self.last_tokens_used: int = 0` to `AgentApi` and intercept the queue in the `finally` block of `send_message()` — or store it in a wrapper class.
+
+### Pitfall 3: AgentApi persistent connection vs sync_forever loop
+
+**What goes wrong:** If `agent_api.connect()` is called inside `_build_platform_from_env()` (sync function), it creates an `asyncio.Task` for `_listen()` outside the event loop context.
+
+**Why it happens:** `_build_platform_from_env()` is called synchronously from `build_runtime()`. `connect()` is a coroutine.
+
+**How to avoid:** Do NOT call `agent_api.connect()` inside `_build_platform_from_env()`. Instead:
+1. `_build_platform_from_env()` creates `RealPlatformClient` with an unconnected `AgentApi`
+2. `main()` awaits `agent_api.connect()` explicitly after constructing runtime
+
+Expose `agent_api` from `RealPlatformClient` via a property so `main()` can call `connect()` on it.
+
+### Pitfall 4: !load numeric input interception
+
+**What goes wrong:** When user types `1` in response to `!load` menu, it is dispatched as `IncomingMessage` (not a command) and routed to the platform — the agent receives "1" as a user message.
+
+**Why it happens:** The Matrix converter (`from_room_event`) produces `IncomingMessage` for plain text, `IncomingCommand` only for `!`-prefixed text.
+
+**How to avoid:** In `MatrixBot.on_room_message()`, before calling `dispatcher.dispatch()`, check if `load_pending` state exists for this user+room. If yes and the message text is a digit (or `0`/`!cancel`), handle it as a load selection instead of routing to agent.
+
+### Pitfall 5: platform-agent thread_id removal breaks existing tests
+
+**What goes wrong:** `tests/platform/test_agent_session.py` imports `build_thread_key` and tests `process_message` with `thread_id` in query params. After the patch is removed, these tests will fail.
+
+**Why it happens:** Tests were written against our patched `external.py`.
+
+**How to avoid:** The plan must include updating `test_agent_session.py` — remove `build_thread_key` tests, update `process_message` tests to reflect origin/main signature (no `thread_id` param).
+
+### Pitfall 6: !reset dialog conflicts with existing !yes/!no flow
+
+**What goes wrong:** The existing `pending_confirm` flow uses `!yes`/`!no`. If both `reset_pending` and `pending_confirm` are active simultaneously, `!yes` could trigger the wrong handler.
+
+**Why it happens:** Both flows listen for the same commands.
+
+**How to avoid:** `!reset` dialog uses a separate state key `reset_pending:{user_id}:{room_id}`. The handler for `!yes` must check `reset_pending` first, then `pending_confirm`. Document priority in handler code.
+
+---
+
+## Code Examples
+
+### Invoking AgentApi.send_message() in stream_message
+```python
+# Source: external/platform-agent_api/lambda_agent_api/agent_api.py
+async def stream_message(self, user_id: str, chat_id: str, text: str, ...) -> AsyncIterator[MessageChunk]:
+ async for event in self._agent_api.send_message(text):
+ if isinstance(event, MsgEventTextChunk):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ # After loop ends, MsgEventEnd was consumed internally
+ yield MessageChunk(message_id=user_id, delta="", finished=True, tokens_used=self._agent_api.last_tokens_used)
+```
+
+### Handler registration pattern
+```python
+# Source: adapter/matrix/handlers/__init__.py
+def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None, agent_api=None) -> None:
+ # existing...
+ dispatcher.register(IncomingCommand, "save", make_handle_save(agent_api, store))
+ dispatcher.register(IncomingCommand, "load", make_handle_load(agent_api, store))
+ dispatcher.register(IncomingCommand, "reset", make_handle_reset(store))
+ dispatcher.register(IncomingCommand, "context", make_handle_context(store))
+```
+
+### !load pending key
+```python
+# New in adapter/matrix/store.py
+LOAD_PENDING_PREFIX = "matrix_load_pending:"
+
+async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
+ return await store.get(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}")
+
+async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
+ await store.set(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}", data)
+
+async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None:
+ await store.delete(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}")
+```
+
+### platform-agent origin/main process_message (no thread_id)
+```python
+# Source: git show origin/main:src/api/external.py in external/platform-agent
+async def process_message(ws: WebSocket, msg, agent_service: AgentService):
+ match msg:
+ case MsgUserMessage():
+ async for chunk in agent_service.astream(msg.text): # no thread_id arg
+ await ws.send_text(chunk.model_dump_json())
+ await ws.send_text(MsgEventEnd(tokens_used=0).model_dump_json())
+```
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `tokens_used` can be captured by storing in `AgentApi.last_tokens_used` attribute during `_listen()` before it's queued | Architecture Patterns | If `_listen` timing means value is read before queue, token count would be wrong — low risk, easy to test |
+| A2 | Python 3.11 can run `lambda_agent_api` despite `>=3.14` constraint in pyproject.toml | Standard Stack | If code uses 3.14-specific syntax, would fail at runtime — actual code inspected: no 3.14 syntax found |
+| A3 | httpx is preferred over aiohttp for POST /reset (one-shot HTTP) | Standard Stack | Either works; httpx already in deps |
+
+---
+
+## Open Questions
+
+1. **tokens_used capture from AgentApi**
+ - What we know: `MsgEventEnd.tokens_used` is put into `_current_queue` but consumed (not yielded) by `send_message()` generator
+ - What's unclear: Cleanest interception point without modifying `lambda_agent_api` source
+ - Recommendation: Add `last_tokens_used: int = 0` attribute to `AgentApi` and set it in `send_message()`'s `finally` block when draining orphan queue, OR set it in `_listen()` before putting `MsgEventEnd` in queue
+
+2. **!load numeric input dispatch**
+ - What we know: Plain text `1`, `2` arrives as `IncomingMessage`, not `IncomingCommand`
+ - What's unclear: Where to intercept — in `on_room_message()` (bot layer) or in dispatcher pre-hook
+ - Recommendation: Intercept in `MatrixBot.on_room_message()` before `dispatcher.dispatch()`. Keeps dispatcher clean.
+
+3. **lambda_agent_api install in Docker**
+ - What we know: It's a local package in `external/platform-agent_api/`
+ - What's unclear: Whether to install as editable or copy sources
+ - Recommendation: `COPY external/platform-agent_api /build/lambda_agent_api && pip install /build/lambda_agent_api` in Dockerfile
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|-------------|-----------|---------|----------|
+| Python 3.11+ | All | ✓ | System | — |
+| aiohttp | AgentApi WS | ✓ | >=3.9 in deps | — |
+| httpx | POST /reset | ✓ | >=0.27 in deps | aiohttp |
+| matrix-nio | Matrix bot | ✓ | >=0.21 in deps | — |
+| lambda_agent_api | AgentApi | local only | 0.1.0 | — |
+| Docker | Container build | [ASSUMED] standard dev env | — | — |
+| platform-agent (running) | Integration test | local clone | origin/main needed | — |
+
+---
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | pytest + pytest-asyncio (asyncio_mode = "auto") |
+| Config file | pyproject.toml `[tool.pytest.ini_options]` |
+| Quick run command | `pytest tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v` |
+| Full suite command | `pytest tests/ -v` |
+
+### Phase Requirements → Test Map
+
+| Req | Behavior | Test Type | File |
+|-----|----------|-----------|------|
+| Remove build_thread_key | Function gone from sdk/ | unit | `tests/platform/test_agent_session.py` — update/remove |
+| AgentApi replaces AgentSessionClient | `RealPlatformClient` uses `AgentApi` | unit | `tests/platform/test_real.py` — update |
+| !save sends prompt to agent | Command dispatches agent message | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
+| !load shows list | Command returns numbered list | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
+| !load numeric select | Bot intercepts digit, sends load prompt | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
+| !reset shows dialog | Command returns confirmation UI | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
+| !context returns snapshot | Command returns session info | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
+| PrototypeStateStore saved sessions | add/list saved sessions | unit | `tests/platform/test_prototype_state.py` — add |
+
+### Wave 0 Gaps
+- [ ] `tests/platform/test_agent_api_integration.py` — unit tests for `RealPlatformClient` with mocked `AgentApi`
+- [ ] `tests/adapter/matrix/test_context_commands.py` — dedicated module for !save/!load/!reset/!context handlers
+
+---
+
+## Sources
+
+### Primary (HIGH confidence — verified by file read in this session)
+- `external/platform-agent_api/lambda_agent_api/agent_api.py` — AgentApi constructor, connect/close/send_message, _listen loop
+- `external/platform-agent_api/lambda_agent_api/server.py` — MsgEventTextChunk, MsgEventEnd, MsgStatus, AgentEventUnion types
+- `external/platform-agent_api/lambda_agent_api/client.py` — MsgUserMessage type
+- `external/platform-agent/src/api/external.py` — current (patched) and origin/main versions verified via git show
+- `adapter/matrix/handlers/__init__.py` — handler registration pattern
+- `adapter/matrix/store.py` — pending_confirm key pattern
+- `adapter/matrix/bot.py` — MatrixBot, build_runtime, _build_platform_from_env
+- `sdk/agent_session.py` — current AgentSessionClient (to be replaced)
+- `sdk/real.py` — RealPlatformClient (to be updated)
+- `sdk/prototype_state.py` — PrototypeStateStore (to be extended)
+- `core/protocol.py` — IncomingCommand, OutgoingMessage types
+- `pyproject.toml` — dependency versions
+- `external/platform-agent_api/pyproject.toml` — Python version constraint
+
+### Tertiary (LOW confidence)
+- Docker best practices for Python apps [ASSUMED] — standard industry pattern
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- AgentApi interface: HIGH — read source directly
+- platform-agent origin/main diff: HIGH — verified via `git show origin/main`
+- handler registration pattern: HIGH — read all handler files
+- pending_confirm key pattern: HIGH — read store.py directly
+- tokens_used interception: MEDIUM — pattern clear but implementation needs care
+- Docker/docker-compose: MEDIUM — standard pattern, not verified against specific matrix-nio requirements
+
+**Research date:** 2026-04-16
+**Valid until:** 2026-05-16 (lambda_agent_api is local — stable until platform team updates it)
diff --git a/.planning/phases/05-mvp-deployment/05-01-PLAN.md b/.planning/phases/05-mvp-deployment/05-01-PLAN.md
new file mode 100644
index 0000000..2320eda
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-01-PLAN.md
@@ -0,0 +1,158 @@
+---
+phase: 05-mvp-deployment
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - adapter/matrix/reconciliation.py
+ - adapter/matrix/bot.py
+ - tests/adapter/matrix/test_reconciliation.py
+ - tests/adapter/matrix/test_restart_persistence.py
+autonomous: true
+requirements:
+ - PH05-01
+ - PH05-03
+must_haves:
+ truths:
+ - "On restart, existing Matrix Space and child-room topology is rebuilt before live sync begins."
+ - "Restart recovery preserves Space+rooms UX instead of creating duplicate DM-style working rooms."
+ - "Recovered rooms regain user metadata, room metadata, and chat bindings needed for normal routing."
+ - "Legacy working rooms missing `platform_chat_id` are backfilled deterministically during startup before strict routing handles traffic."
+ artifacts:
+ - path: "adapter/matrix/reconciliation.py"
+ provides: "Authoritative restart reconciliation from Matrix topology into local metadata"
+ - path: "adapter/matrix/bot.py"
+ provides: "Startup wiring that runs reconciliation before sync_forever"
+ - path: "tests/adapter/matrix/test_reconciliation.py"
+ provides: "Regression coverage for startup recovery and idempotence"
+ key_links:
+ - from: "adapter/matrix/bot.py"
+ to: "adapter/matrix/reconciliation.py"
+ via: "startup bootstrap before sync_forever"
+ pattern: "reconcil"
+ - from: "adapter/matrix/reconciliation.py"
+ to: "core/chat.py"
+ via: "chat manager rebuild for recovered rooms"
+ pattern: "get_or_create"
+---
+
+
+Rebuild Matrix-local routing state from authoritative Space topology before the bot processes live traffic.
+
+Purpose: Preserve the Phase 01 Space+rooms contract after restart even if SQLite metadata is partial or missing.
+Output: A startup reconciliation module, bot wiring, and regression tests proving no DM-first duplication on restart.
+
+
+
+@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
+@/Users/a/.codex/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/05-mvp-deployment/05-RESEARCH.md
+@.planning/phases/05-mvp-deployment/05-VALIDATION.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
+@adapter/matrix/bot.py
+@adapter/matrix/store.py
+@adapter/matrix/handlers/auth.py
+@tests/adapter/matrix/test_invite_space.py
+@tests/adapter/matrix/test_chat_space.py
+@tests/adapter/matrix/test_restart_persistence.py
+
+
+From `adapter/matrix/bot.py`:
+
+```python
+async def prepare_live_sync(client: AsyncClient) -> str | None:
+ response = await client.sync(timeout=0, full_state=True)
+ if isinstance(response, SyncResponse):
+ return response.next_batch
+ return None
+```
+
+```python
+class MatrixBot:
+ async def _bootstrap_unregistered_room(
+ self,
+ room: MatrixRoom,
+ sender: str,
+ ) -> list[OutgoingEvent] | None: ...
+```
+
+From `adapter/matrix/store.py`:
+
+```python
+async def get_room_meta(store: StateStore, room_id: str) -> dict | None: ...
+async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: ...
+async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: ...
+async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: ...
+async def next_platform_chat_id(store: StateStore) -> str: ...
+```
+
+
+
+
+
+
+ Task 1: Add restart reconciliation regression coverage
+ tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py
+ tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_restart_persistence.py, adapter/matrix/bot.py, adapter/matrix/handlers/auth.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md
+
+ - Test 1: startup recovery rebuilds user space metadata, room metadata, and chat bindings from Matrix topology without creating new working rooms (per D-Phase05-reset and PH05-01).
+ - Test 2: reconciliation is idempotent and safe when local SQLite state is already present.
+ - Test 3: reconciliation happens before lazy `_bootstrap_unregistered_room()` would run for existing rooms (per PH05-03).
+ - Test 4: legacy room metadata missing `platform_chat_id` is backfilled deterministically at startup and persisted before routed handling begins.
+
+
+ - `tests/adapter/matrix/test_reconciliation.py` exists and names reconciliation entrypoints explicitly.
+ - The new tests assert restored `space_id`, `chat_id`, `matrix_user_id`, and `platform_chat_id` values for recovered rooms.
+ - The regression slice also proves existing Space onboarding behavior still passes by running `test_invite_space.py` and `test_chat_space.py`.
+ - The automated command in `` fails before implementation or would fail if reconciliation is removed.
+
+ Create a dedicated `tests/adapter/matrix/test_reconciliation.py` module and extend restart persistence coverage so Phase 05 has a real Wave 0 contract. Model the recovered topology after the Phase 01 Space+rooms onboarding tests, not a DM-first flow, and explicitly keep those onboarding regressions in the verification slice so restart hardening cannot break provisioning UX. Cover recovery of `user_meta`, `room_meta`, `ChatManager` bindings, and room-local routing fields from Matrix-side state before live callbacks begin, including deterministic backfill for legacy rooms that predate `platform_chat_id`. Keep temporary UX state out of scope, per research.
+
+ pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v
+
+ Phase 05 has failing-or-red-before-code tests that define authoritative restart reconciliation behavior and exclude duplicate room provisioning.
+
+
+
+ Task 2: Implement authoritative startup reconciliation and wire it before live sync
+ adapter/matrix/reconciliation.py, adapter/matrix/bot.py
+ adapter/matrix/bot.py, adapter/matrix/store.py, adapter/matrix/handlers/auth.py, tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md
+
+ - Test 1: startup rebuild runs after login and initial full-state fetch, but before `sync_forever()` processes live events.
+ - Test 2: recovered rooms keep their existing Space+rooms identity and do not trigger `_bootstrap_unregistered_room()` unless the room is genuinely new.
+ - Test 3: local metadata can be rebuilt from Matrix topology when SQLite entries are missing, while existing valid metadata remains stable.
+ - Test 4: startup repair assigns a deterministic `platform_chat_id` to legacy rooms missing that field and persists it before routed platform calls can occur.
+
+
+ - `adapter/matrix/reconciliation.py` exports a focused reconciliation entrypoint used by startup code.
+ - `adapter/matrix/bot.py` invokes reconciliation before `client.sync_forever(...)`.
+ - Recovered room metadata includes `room_type`, `chat_id`, `space_id`, `matrix_user_id`, and `platform_chat_id` where available or rebuildable.
+ - Legacy rooms missing `platform_chat_id` follow one documented startup backfill path rather than ad hoc routing fallbacks.
+
+ Implement a restart recovery module that treats Matrix topology as authoritative, per the Phase 05 reset and research notes. Rebuild missing local metadata for Space-owned working rooms, deterministically backfill missing `platform_chat_id` values for legacy rooms, and re-create `ChatManager` entries needed by routing, while keeping SQLite as a rebuildable cache rather than the source of truth. Wire the new reconciliation step into startup after the initial full-state sync and before live sync begins, and keep the onboarding regression slice green while doing it. Do not widen into timeline scraping, new storage backends, or DM-first fallbacks.
+
+ pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v
+
+ Restart recovery restores the minimum durable state for existing Space rooms before live traffic, and the guarded regression suite passes.
+
+
+
+
+
+Run the onboarding, reconciliation, restart-persistence, and Matrix dispatcher slices together. Confirm startup now has a deterministic pre-sync recovery and legacy-room backfill step instead of relying on lazy room bootstrap or routing-time fallbacks for existing topology.
+
+
+
+The bot can restart with partial or empty local room metadata, rebuild managed Space rooms before live sync, and continue handling those rooms without creating duplicate onboarding rooms.
+
+
+
diff --git a/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md
new file mode 100644
index 0000000..c50f371
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md
@@ -0,0 +1,99 @@
+---
+phase: 05-mvp-deployment
+plan: 01
+subsystem: infra
+tags: [matrix, reconciliation, sqlite, startup, testing]
+requires:
+ - phase: 01-matrix-mvp
+ provides: Space+rooms onboarding, room metadata, and Matrix dispatcher behavior
+ - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+ provides: durable platform_chat_id and restart persistence primitives
+provides:
+ - authoritative startup reconciliation from Matrix room topology into local metadata
+ - pre-sync startup wiring that repairs managed rooms before live traffic
+ - restart regression coverage for reconciliation, idempotence, and legacy platform_chat_id backfill
+affects: [matrix, startup, deployment, restart-persistence]
+tech-stack:
+ added: []
+ patterns: [matrix-topology-as-source-of-truth, sqlite-cache-rebuild, pre-sync-reconciliation]
+key-files:
+ created: [adapter/matrix/reconciliation.py, tests/adapter/matrix/test_reconciliation.py]
+ modified: [adapter/matrix/bot.py, tests/adapter/matrix/test_restart_persistence.py]
+key-decisions:
+ - "Treat synced Matrix parent/child topology as authoritative for managed room recovery; keep SQLite rebuildable."
+ - "Backfill missing platform_chat_id values during startup reconciliation instead of routing-time fallbacks."
+patterns-established:
+ - "Startup runs full-state sync, then reconciliation, then sync_forever."
+ - "Recovered Matrix rooms rebuild user_meta, room_meta, auth state, and ChatManager bindings idempotently."
+requirements-completed: [PH05-01, PH05-03]
+duration: 8min
+completed: 2026-04-27
+---
+
+# Phase 05 Plan 01: Restart Reconciliation Summary
+
+**Matrix startup now rebuilds Space-owned working rooms into durable local routing state before live sync begins**
+
+## Performance
+
+- **Duration:** 8 min
+- **Started:** 2026-04-27T22:00:47Z
+- **Completed:** 2026-04-27T22:08:47Z
+- **Tasks:** 2
+- **Files modified:** 4
+
+## Accomplishments
+- Added a dedicated reconciliation module that restores `user_meta`, `room_meta`, auth state, chat bindings, and missing `platform_chat_id` values from the synced Matrix room graph.
+- Wired startup to run reconciliation immediately after the initial full-state sync and before `sync_forever()`.
+- Added regression coverage for recovery, idempotence, pre-sync ordering, onboarding compatibility, and legacy restart backfill.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add restart reconciliation regression coverage** - `a75b26a` (test)
+2. **Task 2: Implement authoritative startup reconciliation and wire it before live sync** - `8a80d00` (feat)
+
+## Files Created/Modified
+- `adapter/matrix/reconciliation.py` - Startup recovery from Matrix topology into local room and user metadata.
+- `adapter/matrix/bot.py` - Startup wiring that runs reconciliation after the bootstrap sync and before live sync.
+- `tests/adapter/matrix/test_reconciliation.py` - Recovery, idempotence, and startup-order regression coverage.
+- `tests/adapter/matrix/test_restart_persistence.py` - Legacy `platform_chat_id` backfill persistence coverage.
+
+## Decisions Made
+- Used the synced Matrix room graph as the authoritative source for restart recovery, while preserving existing local metadata whenever it is already valid.
+- Kept legacy `platform_chat_id` repair on a single startup path so routed handling never needs ad hoc fallback creation for existing rooms.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Switched verification to a clean `uv run pytest` environment**
+- **Found during:** Task 1 and Task 2 verification
+- **Issue:** The default `pytest` path used a mismatched virtualenv without repo dependencies, and `.env` injected Matrix backend variables that polluted mock-mode tests.
+- **Fix:** Ran the verification slice through `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces` and blank `MATRIX_AGENT_REGISTRY_PATH` / `MATRIX_PLATFORM_BACKEND` values to match the intended test environment.
+- **Files modified:** None
+- **Verification:** `uv run pytest` slice passed with 50/50 tests green
+- **Committed in:** not applicable (verification-only adjustment)
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking)
+**Impact on plan:** Verification needed an environment correction, but code scope stayed within the plan and owned files.
+
+## Issues Encountered
+- The shell environment loaded deployment-oriented Matrix backend settings from `.env`; these had to be neutralized for the mock-mode regression slice.
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+- Restart recovery is in place for existing Space rooms, including deterministic legacy `platform_chat_id` repair.
+- Remaining Phase 05 plans can build on a stable pre-sync recovery path instead of lazy bootstrap for existing topology.
+
+## Self-Check: PASSED
+
+---
+*Phase: 05-mvp-deployment*
+*Completed: 2026-04-27*
diff --git a/.planning/phases/05-mvp-deployment/05-02-PLAN.md b/.planning/phases/05-mvp-deployment/05-02-PLAN.md
new file mode 100644
index 0000000..dc93cf0
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-02-PLAN.md
@@ -0,0 +1,156 @@
+---
+phase: 05-mvp-deployment
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - 05-01
+files_modified:
+ - adapter/matrix/handlers/__init__.py
+ - adapter/matrix/handlers/context_commands.py
+ - adapter/matrix/routed_platform.py
+ - tests/adapter/matrix/test_context_commands.py
+ - tests/adapter/matrix/test_routed_platform.py
+autonomous: true
+requirements:
+ - PH05-02
+must_haves:
+ truths:
+ - "Each working Matrix room uses its own durable `platform_chat_id` as the real agent context boundary."
+ - "`!clear` resets only the current room by rotating its `platform_chat_id` and disconnecting the old upstream chat."
+ - "Save, load, context, and routed send paths resolve through room-local platform context, not shared user state."
+ - "Strict room routing assumes startup reconciliation has already repaired legacy rooms missing `platform_chat_id`."
+ artifacts:
+ - path: "adapter/matrix/handlers/context_commands.py"
+ provides: "Room-local `!clear`, save/load/context resolution, and upstream disconnect behavior"
+ - path: "adapter/matrix/routed_platform.py"
+ provides: "Strict room -> agent_id + platform_chat_id routing"
+ - path: "tests/adapter/matrix/test_context_commands.py"
+ provides: "Regression coverage for `!clear` and room-local context commands"
+ key_links:
+ - from: "adapter/matrix/handlers/__init__.py"
+ to: "adapter/matrix/handlers/context_commands.py"
+ via: "IncomingCommand registration for `clear`"
+ pattern: "\"clear\""
+ - from: "adapter/matrix/routed_platform.py"
+ to: "adapter/matrix/store.py"
+ via: "room metadata lookup"
+ pattern: "platform_chat_id"
+---
+
+
+Make room-local platform context explicit and user-facing by shipping real `!clear` semantics and strict per-room routing.
+
+Purpose: Phase 05 must preserve Space+rooms UX while giving each room a true upstream context boundary.
+Output: Updated command wiring, room-local context reset behavior, and routing regressions tied to `platform_chat_id`.
+
+
+
+@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
+@/Users/a/.codex/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/05-mvp-deployment/05-RESEARCH.md
+@.planning/phases/05-mvp-deployment/05-VALIDATION.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md
+@adapter/matrix/handlers/__init__.py
+@adapter/matrix/handlers/context_commands.py
+@adapter/matrix/routed_platform.py
+@tests/adapter/matrix/test_context_commands.py
+@tests/adapter/matrix/test_routed_platform.py
+
+
+From `adapter/matrix/handlers/__init__.py`:
+
+```python
+dispatcher.register(
+ IncomingCommand,
+ "reset",
+ make_handle_reset(store, prototype_state)
+ if prototype_state is not None
+ else handle_settings,
+)
+```
+
+From `adapter/matrix/handlers/context_commands.py`:
+
+```python
+async def _resolve_context_scope(
+ event: IncomingCommand,
+ store: StateStore,
+ chat_mgr,
+) -> tuple[str, str | None]: ...
+```
+
+From `adapter/matrix/routed_platform.py`:
+
+```python
+async def _resolve_delegate(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
+ ...
+```
+
+
+
+
+
+
+ Task 1: Expand room-local context and clear-command tests
+ tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py
+ tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, .planning/phases/05-mvp-deployment/05-VALIDATION.md
+
+ - Test 1: `!clear` rotates only the current room's `platform_chat_id` and disconnects only the old upstream chat (per PH05-02).
+ - Test 2: `!clear` is the supported command name; `!reset` may remain as a compatibility alias but must not be the only registered path.
+ - Test 3: routed send/stream paths fail fast when room metadata lacks `agent_id` or `platform_chat_id` instead of silently sharing context.
+ - Test 4: routed behavior uses startup-repaired room metadata and does not introduce a second fallback path that invents `platform_chat_id` during message handling.
+
+
+ - Tests explicitly mention `clear` in command registration or command invocation.
+ - The context-command tests assert old and new `platform_chat_id` values and upstream disconnect behavior.
+ - The routed-platform tests assert room-local IDs are passed to delegates unchanged.
+
+ Extend the current Matrix context-command and routed-platform regressions so Phase 05 has direct coverage for `!clear`, room-local `platform_chat_id` rotation, and fail-fast routing when room bindings are incomplete. Treat startup reconciliation from `05-01` as the only supported repair path for legacy rooms missing `platform_chat_id`; the routed path must consume repaired metadata, not synthesize new room identities on demand. Preserve the Phase 04 prototype-state behavior where it still fits, but anchor new checks on per-room context isolation rather than shared session assumptions.
+
+ pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v
+
+ The tests define the Phase 05 room-local contract for reset/clear and for routed upstream calls.
+
+
+
+ Task 2: Ship real room-local `!clear` semantics and strict routing
+ adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py
+ adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md
+
+ - Test 1: command registration exposes `!clear` as the real context-reset entrypoint for Matrix rooms.
+ - Test 2: only the active room's `platform_chat_id` rotates, and only the old upstream chat session is disconnected.
+ - Test 3: all room-local context commands resolve through recovered room metadata instead of falling back to shared user scope.
+ - Test 4: strict routing stays strict at runtime because legacy-room repair was already handled at startup by `05-01`, not by hidden message-path fallbacks.
+
+
+ - `adapter/matrix/handlers/__init__.py` registers `clear`; if `reset` remains, it is clearly a compatibility alias.
+ - `adapter/matrix/handlers/context_commands.py` resolves and rotates room-local platform context without touching other rooms.
+ - `adapter/matrix/routed_platform.py` keeps explicit `MATRIX_ROUTE_INCOMPLETE` behavior when bindings are missing.
+
+ Update the Matrix context command surface to match the Phase 05 contract: real `!clear`, room-local `platform_chat_id` rotation, and upstream disconnect scoped to the old room context. Keep `save`, `load`, and `context` anchored to the same room-local identity. Tighten routed-platform behavior only where needed to preserve fail-fast semantics after startup reconciliation has repaired legacy rooms; do not reintroduce shared chat state, user-level reset behavior, or message-time backfill of missing `platform_chat_id`.
+
+ pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v
+
+ Users can clear one working room without affecting others, and all routed upstream calls stay bound to room-local platform context.
+
+
+
+
+
+Run the context and routed-platform slices plus Matrix dispatcher smoke coverage to confirm the exposed command name and room-local routing behavior are consistent.
+
+
+
+Every working Matrix room has an independent upstream context boundary, and `!clear` resets only the room where it is invoked.
+
+
+
diff --git a/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md
new file mode 100644
index 0000000..fa4a48c
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md
@@ -0,0 +1,106 @@
+---
+phase: 05-mvp-deployment
+plan: 02
+subsystem: matrix
+tags: [matrix, routing, context, platform-chat-id, testing]
+requires:
+ - phase: 05-01
+ provides: startup reconciliation for room metadata before live routing
+provides:
+ - room-local `!clear` coverage and command registration
+ - strict room-local context resolution for save/context flows
+ - fail-fast routed-platform regressions for incomplete room bindings
+affects: [matrix-dispatcher, routed-platform, startup-reconciliation]
+tech-stack:
+ added: []
+ patterns: [per-room platform context, compatibility alias registration, fail-fast routing]
+key-files:
+ created: []
+ modified:
+ - adapter/matrix/handlers/__init__.py
+ - adapter/matrix/handlers/context_commands.py
+ - tests/adapter/matrix/test_context_commands.py
+ - tests/adapter/matrix/test_routed_platform.py
+key-decisions:
+ - "Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias."
+ - "Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids."
+patterns-established:
+ - "Matrix room context commands resolve through room metadata repaired at startup, not message-time backfill."
+ - "Reset semantics rotate one room's upstream chat id and disconnect only that old upstream session."
+requirements-completed: [PH05-02]
+duration: 16 min
+completed: 2026-04-27
+---
+
+# Phase 05 Plan 02: Room-local clear and strict Matrix routing Summary
+
+**Room-local `!clear` command coverage, strict per-room `platform_chat_id` context resolution, and fail-fast Matrix routing regressions**
+
+## Performance
+
+- **Duration:** 16 min
+- **Started:** 2026-04-27T22:00:00Z
+- **Completed:** 2026-04-27T22:15:58Z
+- **Tasks:** 2
+- **Files modified:** 4
+
+## Accomplishments
+- Added RED/GREEN regression coverage for `!clear`, room-local chat-id rotation, and strict routed-platform failure modes.
+- Registered `clear` as the supported reset entrypoint when room-context support exists, while preserving `reset` as a compatibility alias.
+- Removed shared-context fallbacks from save/context handling and fixed reset to clear the old upstream prototype session before rotation.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Expand room-local context and clear-command tests** - `ae37476` (test)
+2. **Task 2: Ship real room-local `!clear` semantics and strict routing** - `85e2fda` (feat)
+
+## Files Created/Modified
+- `adapter/matrix/handlers/__init__.py` - registers `clear` for runtimes with prototype room-context support and keeps `reset` as the compatibility alias.
+- `adapter/matrix/handlers/context_commands.py` - requires room-local `platform_chat_id` for save/context/clear flows and disconnects the old upstream context on clear.
+- `tests/adapter/matrix/test_context_commands.py` - covers room-local clear rotation, upstream disconnect, and clear registration.
+- `tests/adapter/matrix/test_routed_platform.py` - covers incomplete-room fail-fast behavior and repaired metadata routing.
+
+## Decisions Made
+- Exposed `clear` only on runtimes that actually have prototype room-context support so Phase 05 semantics do not break older MVP smoke tests.
+- Treated startup-repaired room metadata as the only supported source of `platform_chat_id` for context commands instead of falling back to local chat ids.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 1 - Bug] Clear path was wiping the new context instead of the old upstream session**
+- **Found during:** Task 2 (Ship real room-local `!clear` semantics and strict routing)
+- **Issue:** `make_handle_reset()` cleared prototype session state for the newly generated chat id, leaving the previous upstream context intact.
+- **Fix:** Cleared prototype state for the old `platform_chat_id` before rotating to the new room-local id, and kept the new context empty as well.
+- **Files modified:** `adapter/matrix/handlers/context_commands.py`
+- **Verification:** `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v`
+- **Committed in:** `85e2fda`
+
+---
+
+**Total deviations:** 1 auto-fixed (1 bug)
+**Impact on plan:** The auto-fix was required for correct room-local clear behavior. No scope creep.
+
+## Issues Encountered
+- Plain `pytest` used an environment without `PyYAML`; verification was switched to `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces`.
+- Shared shell env exposed `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml`, which broke unrelated Matrix smoke tests. Verification was run with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND=''` to match the intended mock-runtime test setup.
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+- Matrix room-local clear semantics and routing contracts are now explicit and covered.
+- Phase 05 follow-on work can assume startup reconciliation remains the only supported repair path for missing room routing metadata.
+
+---
+*Phase: 05-mvp-deployment*
+*Completed: 2026-04-27*
+
+## Self-Check: PASSED
+
+- Found `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md`
+- Found commit `ae37476`
+- Found commit `85e2fda`
diff --git a/.planning/phases/05-mvp-deployment/05-03-PLAN.md b/.planning/phases/05-mvp-deployment/05-03-PLAN.md
new file mode 100644
index 0000000..01023b3
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-03-PLAN.md
@@ -0,0 +1,145 @@
+---
+phase: 05-mvp-deployment
+plan: 03
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - adapter/matrix/files.py
+ - sdk/real.py
+ - tests/adapter/matrix/test_files.py
+ - tests/platform/test_real.py
+autonomous: true
+requirements:
+ - PH05-04
+must_haves:
+ truths:
+ - "Incoming Matrix attachments are written into a room-safe shared-volume path and passed upstream as relative workspace paths."
+ - "Agent-emitted files can be returned to Matrix users without inventing a separate file proxy."
+ - "The shared-volume contract works with the Phase 05 `/agents` deployment shape."
+ artifacts:
+ - path: "adapter/matrix/files.py"
+ provides: "Room-safe shared-volume path building and path resolution"
+ - path: "sdk/real.py"
+ provides: "Attachment path passthrough and send-file normalization"
+ - path: "tests/adapter/matrix/test_files.py"
+ provides: "Regression coverage for shared-volume path construction"
+ key_links:
+ - from: "adapter/matrix/files.py"
+ to: "sdk/real.py"
+ via: "relative `workspace_path` transport"
+ pattern: "workspace_path"
+ - from: "sdk/real.py"
+ to: "adapter/matrix/bot.py"
+ via: "OutgoingMessage attachments rendered back to Matrix"
+ pattern: "MsgEventSendFile"
+---
+
+
+Harden the Matrix attachment path contract around the shared deployment volume instead of custom transport shims.
+
+Purpose: Phase 05 file handling must survive real deployment with room-safe paths and outbound file delivery through the existing shared-volume model.
+Output: Attachment-path regressions and any targeted runtime fixes needed for `/agents`-backed shared volume behavior.
+
+
+
+@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
+@/Users/a/.codex/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/05-mvp-deployment/05-RESEARCH.md
+@.planning/phases/05-mvp-deployment/05-VALIDATION.md
+@docs/deploy-architecture.md
+@docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
+@adapter/matrix/files.py
+@sdk/real.py
+@tests/adapter/matrix/test_files.py
+@tests/platform/test_real.py
+
+
+From `adapter/matrix/files.py`:
+
+```python
+def build_workspace_attachment_path(
+ *,
+ workspace_root: Path,
+ matrix_user_id: str,
+ room_id: str,
+ filename: str,
+ timestamp: str | None = None,
+) -> tuple[str, Path]: ...
+```
+
+From `sdk/real.py`:
+
+```python
+@staticmethod
+def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: ...
+
+@staticmethod
+def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: ...
+```
+
+
+
+
+
+
+ Task 1: Add shared-volume file contract tests for `/agents` deployment
+ tests/adapter/matrix/test_files.py, tests/platform/test_real.py
+ tests/adapter/matrix/test_files.py, tests/platform/test_real.py, adapter/matrix/files.py, sdk/real.py, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md
+
+ - Test 1: incoming Matrix files land under a room-safe `surfaces/matrix/.../inbox/...` path that remains relative to the agent workspace contract.
+ - Test 2: upstream file events normalize `/workspace/...` and `/agents/...`-style absolute paths into relative `workspace_path` values.
+ - Test 3: attachment forwarding never switches to inline blobs or HTTP shim URLs (per PH05-04).
+
+
+ - `tests/adapter/matrix/test_files.py` asserts the path namespace includes sanitized user and room components.
+ - `tests/platform/test_real.py` contains explicit coverage for send-file path normalization.
+ - The automated test command in `` exercises both inbound and outbound sides of the shared-volume contract.
+
+ Expand the file-flow regressions around the real deployment contract described in research and `docs/deploy-architecture.md`. Keep the tests centered on relative `workspace_path` transport and room-safe on-disk layout. Do not introduce proxy URLs, base64 payload transport, or new platform endpoints.
+
+ pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v
+
+ Phase 05 has direct test coverage for `/agents`-backed shared-volume behavior across inbound and outbound file paths.
+
+
+
+ Task 2: Tighten attachment path handling for the shared volume contract
+ adapter/matrix/files.py, sdk/real.py
+ adapter/matrix/files.py, sdk/real.py, tests/adapter/matrix/test_files.py, tests/platform/test_real.py, docs/deploy-architecture.md
+
+ - Test 1: inbound attachment helpers keep returning relative paths even when the bot writes into `/agents`.
+ - Test 2: outbound file normalization accepts absolute paths from the agent runtime but strips them back to relative workspace paths for Matrix rendering.
+ - Test 3: no code path emits non-relative attachment references to the upstream agent API.
+
+
+ - `sdk/real.py` only forwards relative attachment paths to the agent API.
+ - `sdk/real.py` normalizes both `/workspace` and `/agents` absolute roots if present in send-file events.
+ - `adapter/matrix/files.py` remains the single source of truth for room-safe attachment path construction.
+
+ Implement only the minimum runtime changes needed to satisfy the shared-volume tests. Keep `adapter/matrix/files.py` as the single place that builds surface-owned attachment paths, and keep `sdk/real.py` responsible only for attachment passthrough and send-file normalization. Do not widen this plan into compose edits, registry redesign, or bot command changes.
+
+ pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v
+
+ Incoming and outgoing file references stay compatible with the real shared-volume deployment contract, and the targeted file/path regressions pass.
+
+
+
+
+
+Run the Matrix file helper, real platform client, and outgoing-send slices together so the shared-volume contract is validated from write path through return-to-user rendering.
+
+
+
+The Matrix bot and agent runtime can exchange file references through the shared volume using only relative workspace paths and room-safe storage layout.
+
+
+
diff --git a/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md
new file mode 100644
index 0000000..0745e7c
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md
@@ -0,0 +1,103 @@
+---
+phase: 05-mvp-deployment
+plan: 03
+subsystem: infra
+tags: [matrix, attachments, shared-volume, agents, pytest]
+requires:
+ - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma
+ provides: direct AgentApi integration and Matrix outgoing file rendering
+provides:
+ - shared-volume attachment path regressions for /agents deployment
+ - relative workspace-path normalization for upstream attachment transport
+ - send-file event normalization for Matrix outbound file rendering
+affects: [matrix, deployment, shared-volume, file-transfer]
+tech-stack:
+ added: []
+ patterns: [relative workspace_path transport, shared-volume root normalization]
+key-files:
+ created: []
+ modified:
+ - tests/adapter/matrix/test_files.py
+ - tests/platform/test_real.py
+ - sdk/real.py
+key-decisions:
+ - "Keep adapter/matrix/files.py as the only path-construction source; sdk/real.py only normalizes attachment references crossing the shared-volume boundary."
+ - "Normalize both /workspace and /agents absolute paths back to relative workspace paths before forwarding to the agent API or rendering Matrix send-file events."
+patterns-established:
+ - "Matrix attachment transport stays relative even when the bot sees absolute shared-volume paths."
+ - "Agent-emitted file paths are rendered back to Matrix without HTTP proxy shims or inline blobs."
+requirements-completed: [PH05-04]
+duration: 3 min
+completed: 2026-04-27
+---
+
+# Phase 05 Plan 03: Shared-volume attachment path hardening Summary
+
+**Room-safe Matrix inbox paths and relative shared-volume attachment normalization for `/agents` deployment**
+
+## Performance
+
+- **Duration:** 3 min
+- **Started:** 2026-04-27T22:02:34Z
+- **Completed:** 2026-04-27T22:05:41Z
+- **Tasks:** 2
+- **Files modified:** 3
+
+## Accomplishments
+- Added regression coverage for room-safe `surfaces/matrix/.../inbox/...` attachment layout under `/agents` workspaces.
+- Added explicit tests for `/workspace/...` and `/agents/...` attachment-path normalization on both upstream send and downstream send-file rendering.
+- Tightened `RealPlatformClient` so only relative `workspace_path` values are forwarded to the agent API.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add shared-volume file contract tests for `/agents` deployment** - `cafb0ec` (test)
+2. **Task 2: Tighten attachment path handling for the shared volume contract** - `9a03160` (fix)
+
+## Files Created/Modified
+- `tests/adapter/matrix/test_files.py` - covers room-safe shared-volume inbox paths under an `/agents` workspace root.
+- `tests/platform/test_real.py` - covers relative attachment forwarding and send-file normalization for `/workspace` and `/agents` paths.
+- `sdk/real.py` - normalizes shared-volume roots before attachments cross the upstream or Matrix rendering boundary.
+
+## Decisions Made
+- Kept `adapter/matrix/files.py` unchanged so path construction remains centralized there.
+- Reused one normalization helper in `sdk/real.py` for both `_attachment_paths()` and `_attachment_from_send_file_event()` to keep inbound and outbound behavior aligned.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Adjusted verification to use the project uv environment**
+- **Found during:** Task 2 (Tighten attachment path handling for the shared volume contract)
+- **Issue:** The plan's raw `pytest` command collected with an interpreter missing `PyYAML`, which blocked `tests/adapter/matrix/test_send_outgoing.py` before runtime assertions could execute.
+- **Fix:** Re-ran verification with `UV_CACHE_DIR=/tmp/uv-cache uv run pytest ...`, matching the repo's `CLAUDE.md` guidance and the project `.venv`.
+- **Files modified:** None
+- **Verification:** `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v`
+- **Committed in:** None (verification-environment adjustment only)
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking)
+**Impact on plan:** No scope creep. The deviation only changed the verification entrypoint so the planned test slice could run in the correct environment.
+
+## Issues Encountered
+- The ambient `pytest` resolved to a different virtualenv than the project `.venv`, so direct verification was unreliable for dependency-backed Matrix tests.
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+- Shared-volume file references now stay relative across inbound bot downloads, agent API attachment transport, and outbound Matrix send-file rendering.
+- Phase 05 compose and deployment work can assume the `/agents` contract without adding file proxy infrastructure.
+
+## Self-Check: PASSED
+
+- Found `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md`
+- Verified commit `cafb0ec` exists in git history
+- Verified commit `9a03160` exists in git history
+
+---
+*Phase: 05-mvp-deployment*
+*Completed: 2026-04-27*
diff --git a/.planning/phases/05-mvp-deployment/05-04-PLAN.md b/.planning/phases/05-mvp-deployment/05-04-PLAN.md
new file mode 100644
index 0000000..4fe2235
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-04-PLAN.md
@@ -0,0 +1,128 @@
+---
+phase: 05-mvp-deployment
+plan: 04
+type: execute
+wave: 2
+depends_on:
+ - 05-03
+files_modified:
+ - docker-compose.prod.yml
+ - docker-compose.fullstack.yml
+ - Dockerfile
+ - .env.example
+ - README.md
+ - docs/deploy-architecture.md
+autonomous: true
+requirements:
+ - PH05-05
+must_haves:
+ truths:
+ - "Production handoff uses a bot-only compose artifact instead of the internal full-stack harness."
+ - "Internal E2E compose brings up the bot, platform-agent, and shared volume with explicit health-gated startup."
+ - "Deployment docs and env examples match the split compose artifacts and shared `/agents` contract."
+ artifacts:
+ - path: "docker-compose.prod.yml"
+ provides: "Bot-only deployment handoff artifact"
+ - path: "docker-compose.fullstack.yml"
+ provides: "Internal E2E harness with shared volume and dependency gating"
+ - path: ".env.example"
+ provides: "Documented runtime contract for Phase 05 deployment"
+ key_links:
+ - from: "docker-compose.fullstack.yml"
+ to: "docker-compose.prod.yml"
+ via: "shared service definition or explicit duplication"
+ pattern: "matrix-bot"
+ - from: "docs/deploy-architecture.md"
+ to: "docker-compose.prod.yml"
+ via: "operator handoff instructions"
+ pattern: "prod"
+---
+
+
+Split deployment artifacts by operational intent so operator handoff and internal E2E testing stop sharing the same compose contract.
+
+Purpose: Phase 05 needs an explicit bot-only production artifact and a separate full-stack compose harness aligned with the shared-volume design.
+Output: `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and updated env/docs describing when to use each.
+
+
+
+@/Users/a/.codex/get-shit-done/workflows/execute-plan.md
+@/Users/a/.codex/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/05-mvp-deployment/05-RESEARCH.md
+@.planning/phases/05-mvp-deployment/05-VALIDATION.md
+@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md
+@docs/deploy-architecture.md
+@docker-compose.yml
+@Dockerfile
+@.env.example
+
+
+Current root compose contract:
+
+```yaml
+services:
+ platform-agent:
+ ...
+ matrix-bot:
+ build: .
+ env_file: .env
+ environment:
+ AGENT_BASE_URL: http://platform-agent:8000
+ SURFACES_WORKSPACE_DIR: /workspace
+```
+
+
+
+
+
+
+ Task 1: Create split prod and fullstack compose artifacts
+ docker-compose.prod.yml, docker-compose.fullstack.yml, Dockerfile, .env.example
+ docker-compose.yml, Dockerfile, .env.example, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md, .planning/phases/05-mvp-deployment/05-VALIDATION.md
+
+ - `docker-compose.prod.yml` defines only the bot-side runtime required for operator handoff.
+ - `docker-compose.fullstack.yml` includes the internal platform-agent service, shared volume mounts, and health-gated startup rather than sleep-based sequencing.
+ - `.env.example` documents the Phase 05 env contract without requiring the reader to inspect the old root compose file.
+
+ Create two explicit compose artifacts per PH05-05: a bot-only `docker-compose.prod.yml` for deployment handoff and a `docker-compose.fullstack.yml` for internal E2E runs. Align mounts and env values with the Phase 05 shared `/agents` volume direction from research. Reuse the existing Dockerfile unless a small compatibility edit is required. Do not keep the old single-file compose setup as the only documented runtime.
+
+ docker compose -f docker-compose.prod.yml config > /tmp/phase05-prod-compose.yml && docker compose -f docker-compose.fullstack.yml config > /tmp/phase05-fullstack-compose.yml && rg -n "^services:$|^ matrix-bot:$|^volumes:$|/agents" /tmp/phase05-prod-compose.yml && test -z "$(rg -n "^ platform-agent:$" /tmp/phase05-prod-compose.yml)" && rg -n "^ matrix-bot:$|^ platform-agent:$|condition: service_healthy|healthcheck:|/agents" /tmp/phase05-fullstack-compose.yml
+
+ Both compose files render successfully and express distinct operational roles: prod handoff vs internal full-stack testing.
+
+
+
+ Task 2: Update deployment docs and operator guidance for the split artifacts
+ README.md, docs/deploy-architecture.md
+ README.md, docs/deploy-architecture.md, docker-compose.prod.yml, docker-compose.fullstack.yml, .env.example
+
+ - README or deploy doc tells the operator exactly which compose file to use for production vs internal E2E.
+ - The docs describe the shared `/agents` volume behavior and reference the relevant env vars.
+ - The old root `docker-compose.yml` is no longer the primary documented deployment path.
+
+ Update the repo docs so the Phase 05 deployment story is executable without inference: production handoff stays bot-only, full-stack compose is for internal E2E, and shared-volume file behavior is described in the same terms as the runtime artifacts. Keep documentation narrowly scoped to the shipped compose split; do not widen into platform-master or future storage design.
+
+ rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example && rg -n "production|deploy" README.md docs/deploy-architecture.md | rg "docker-compose\\.prod" && test -z "$(rg -n "docker compose up|docker-compose\\.yml" README.md docs/deploy-architecture.md | rg "production|deploy")"
+
+ The docs and env guidance match the new compose artifacts and no longer imply a single shared deployment file.
+
+
+
+
+
+Render both compose files, then grep the docs for the new artifact names and `/agents` references so the operator contract is explicit and consistent.
+
+
+
+An operator can deploy the Matrix bot with the bot-only compose file, while developers can run the internal end-to-end harness separately without reinterpreting the deployment docs.
+
+
+
diff --git a/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md
new file mode 100644
index 0000000..68a62c6
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md
@@ -0,0 +1,93 @@
+---
+phase: 05-mvp-deployment
+plan: 04
+subsystem: infra
+tags: [docker-compose, matrix, deployment, agents, docs]
+requires:
+ - phase: 05-03
+ provides: "Shared /agents attachment contract and path normalization for Matrix runtime"
+provides:
+ - "docker-compose.prod.yml bot-only deployment handoff artifact"
+ - "docker-compose.fullstack.yml internal E2E harness with health-gated platform-agent startup"
+ - "README and deploy architecture docs aligned to the split compose contract"
+affects: [mvp-deployment, operator-handoff, internal-e2e]
+tech-stack:
+ added: [Docker Compose]
+ patterns: [split-compose-by-operational-intent, shared-agents-volume-contract]
+key-files:
+ created: [docker-compose.prod.yml, docker-compose.fullstack.yml]
+ modified: [.env.example, README.md, docs/deploy-architecture.md]
+key-decisions:
+ - "Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification."
+ - "Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same volume."
+patterns-established:
+ - "Production operators use docker-compose.prod.yml and provide an external AGENT_BASE_URL."
+ - "Internal verification uses docker-compose.fullstack.yml with service_healthy gating instead of sleep-based startup."
+requirements-completed: [PH05-05]
+duration: 3 min
+completed: 2026-04-27
+---
+
+# Phase 05 Plan 04: Split deployment artifacts Summary
+
+**Bot-only production compose handoff plus a separate health-gated full-stack harness aligned to the shared `/agents` volume contract**
+
+## Performance
+
+- **Duration:** 3 min
+- **Started:** 2026-04-27T22:12:42Z
+- **Completed:** 2026-04-27T22:16:09Z
+- **Tasks:** 2
+- **Files modified:** 5
+
+## Accomplishments
+- Added `docker-compose.prod.yml` as the operator-facing bot-only runtime artifact.
+- Added `docker-compose.fullstack.yml` for internal E2E runs with `platform-agent` health-gated startup.
+- Updated env and deployment docs so `/agents` and the split compose flow are explicit without relying on the old root compose file.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Create split prod and fullstack compose artifacts** - `df6d8bf` (feat)
+2. **Task 2: Update deployment docs and operator guidance for the split artifacts** - `22a3a2b` (docs)
+
+**Plan metadata:** pending final docs commit after state updates
+
+## Files Created/Modified
+- `docker-compose.prod.yml` - bot-only deployment handoff with `/agents` volume contract
+- `docker-compose.fullstack.yml` - internal harness extending the bot service and adding health-checked `platform-agent`
+- `.env.example` - Phase 05 runtime variables for prod handoff and internal full-stack defaults
+- `README.md` - operator-facing instructions for choosing the correct compose artifact
+- `docs/deploy-architecture.md` - deployment topology and shared-volume guidance aligned to the split artifacts
+
+## Decisions Made
+- Split deployment packaging by operational intent instead of overloading one compose file for both prod handoff and internal testing.
+- Kept the bot’s absolute shared path rooted at `/agents` in docs and env examples while letting the internal `platform-agent` consume the same named volume at `/workspace`.
+
+## Deviations from Plan
+
+None - plan executed exactly as written.
+
+## Issues Encountered
+
+- The docs verification grep treated `deploy` inside the filename `docs/deploy-architecture.md` as a false positive when root compose was still named explicitly. The fix was to remove the literal root compose filename from deploy-path wording while keeping the deprecation clear.
+
+## User Setup Required
+
+None - no external service configuration required beyond populating `.env` from `.env.example`.
+
+## Next Phase Readiness
+
+- Operators now have a bot-only compose artifact for handoff, and developers have a separate internal E2E harness.
+- Remaining Phase 05 work can rely on the split runtime contract without reinterpreting deployment docs.
+
+## Self-Check: PASSED
+
+- Summary file exists at `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md`
+- Commit `df6d8bf` found in git history
+- Commit `22a3a2b` found in git history
+
+---
+*Phase: 05-mvp-deployment*
+*Completed: 2026-04-27*
diff --git a/.planning/phases/05-mvp-deployment/05-RESEARCH.md b/.planning/phases/05-mvp-deployment/05-RESEARCH.md
new file mode 100644
index 0000000..6ccb0cd
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-RESEARCH.md
@@ -0,0 +1,411 @@
+# Phase 05: MVP Deployment - Research
+
+**Researched:** 2026-04-28
+**Domain:** Matrix bot production deployment, restart reconciliation, per-room context isolation, shared-volume file transfer
+**Confidence:** HIGH
+
+## Project Constraints (from CLAUDE.md)
+
+- All platform calls must stay behind `platform/interface.py` (`PlatformClient` protocol).
+- Current platform implementation is a mock / replaceable adapter; architecture must not depend on unfinished upstream SDK.
+- Keep architecture decisions inside this repo and document contracts locally.
+- Prefer async, adapter/core separation, and do not bypass the existing `core/` and `adapter/` layering.
+- Use `uv sync` for dependency installation.
+- Use `pytest tests/ -v` and adapter-specific pytest slices for verification.
+- Never commit `.env`.
+- Dependency order remains fixed: `core/` first, `platform/` second, adapters after that.
+
+## Summary
+
+Phase 05 should not introduce a new stack. The established implementation path is to harden the existing `matrix-nio + SQLiteStore + RoutedPlatformClient + shared workspace volume` design so production restart behavior matches the current Space+rooms UX. The main architectural rule is: Matrix topology is authoritative for room existence, while local SQLite metadata is authoritative only after reconciliation has rebuilt it.
+
+The production-safe approach is to bind every working Matrix room to its own durable `platform_chat_id`, rotate only that identifier for `!clear`, and make restart recovery idempotent. Reconciliation should rebuild `user_meta`, `room_meta`, `ChatManager` entries, and missing routing fields from Matrix Space membership and room state before `sync_forever()` begins processing live traffic. Unknown rooms must be reconciled first, not silently converted into new chats.
+
+For files, keep the current shared-volume contract and relative `workspace_path` transport. Do not build HTTP file shims or embed file payloads in bot-side state. For deployment artifacts, split runtime intent explicitly: `docker-compose.prod.yml` is a bot-only handoff contract, while `docker-compose.fullstack.yml` is the internal E2E harness that brings up platform services and shared volumes together.
+
+**Primary recommendation:** Implement Phase 05 as a reconciliation-and-deploy hardening pass on the current Matrix stack, with Matrix Space state as source of truth and per-room `platform_chat_id` as the routing key.
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `matrix-nio` | 0.25.2 | Async Matrix client, Spaces, media upload/download, token login, sync loop | Already in repo; official docs confirm support for Spaces, token login, `room_put_state`, `upload`, `download`, and `sync_forever` |
+| `sqlite3` / `SQLiteStore` | stdlib / repo-local | Durable bot metadata (`room_meta`, `user_meta`, routing state) | Small, local, restart-safe KV layer already used by runtime and tests |
+| `PyYAML` | 6.0.3 | Agent registry / deployment config parsing | Current repo standard for `config/matrix-agents.yaml`-style artifacts |
+| `httpx` | 0.28.1 | Async HTTP for auxiliary platform calls | Already used; fits async runtime and current codebase |
+| Docker Compose | v2 spec; local install `v2.40.3` | Prod/fullstack topology, shared named volumes, health-gated startup | Officially supports multi-file overlays, named volumes, and `service_healthy` gating |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `structlog` | 25.5.0 | Structured runtime logging | Use for reconciliation summaries, routing mismatches, and deploy diagnostics |
+| `pydantic` | 2.13.3 | Typed config / payload validation | Use for any new deployment config or reconciliation report structures |
+| `python-dotenv` | 1.2.2 | Local env loading | Keep for local and compose-driven runtime config |
+| `pytest` | 9.0.3 | Test runner | Full phase verification and regression slices |
+| `pytest-asyncio` | 1.3.0 | Async test execution | Required for reconciliation/runtime tests |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `matrix-nio` | Synapse Admin / raw Matrix HTTP calls | Worse fit; repo already depends on nio abstractions and tests |
+| repo-local `SQLiteStore` | Redis/Postgres | Unnecessary operational scope increase for MVP deployment |
+| shared volume file flow | custom file proxy / presigned URLs | More moving parts, more auth/cleanup edge cases, no need for MVP |
+| split compose files | one overloaded compose file with profiles | Harder operator handoff; less explicit prod vs internal-test intent |
+
+**Installation:**
+```bash
+uv sync
+```
+
+**Version verification:** Verified on 2026-04-28 from PyPI and local environment.
+
+| Package | Verified Version | Publish Date | Source |
+|---------|------------------|--------------|--------|
+| `matrix-nio` | 0.25.2 | 2024-10-04 | PyPI |
+| `httpx` | 0.28.1 | 2024-12-06 | PyPI |
+| `structlog` | 25.5.0 | 2025-10-27 | PyPI |
+| `pydantic` | 2.13.3 | 2026-04-20 | PyPI |
+| `aiohttp` | 3.13.5 | 2026-03-31 | PyPI |
+| `PyYAML` | 6.0.3 | 2025-09-25 | PyPI |
+| `python-dotenv` | 1.2.2 | 2026-03-01 | PyPI |
+| `pytest` | 9.0.3 | 2026-04-07 | PyPI |
+| `pytest-asyncio` | 1.3.0 | 2025-11-10 | PyPI |
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```text
+adapter/matrix/
+├── bot.py # startup, sync bootstrap, live callbacks
+├── reconciliation.py # new: restart recovery from Matrix state
+├── files.py # shared-volume path building / materialization
+├── routed_platform.py # room -> agent_id + platform_chat_id routing
+├── store.py # room_meta/user_meta helpers and counters
+└── handlers/
+ ├── auth.py # Space + first room provisioning
+ ├── chat.py # !new / !archive / !rename
+ └── context_commands.py # !save / !load / !clear / !context
+
+deploy/
+├── docker-compose.prod.yml # bot-only handoff
+└── docker-compose.fullstack.yml # internal E2E stack
+```
+
+### Pattern 1: Matrix Space State Is Canonical, SQLite Is Rebuildable
+**What:** Treat Matrix Space membership and child-room state as the source of truth for room topology; use local SQLite metadata as a cached routing index that reconciliation can rebuild.
+**When to use:** Startup, DB loss, stale local metadata, and any deployment where rooms may outlive the bot process.
+**Example:**
+```python
+# Source: repo pattern from adapter/matrix/store.py + Matrix Space state
+room_meta = {
+ "room_type": "chat",
+ "chat_id": "C7",
+ "display_name": "Research",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "agent_id": "agent-1",
+ "platform_chat_id": "42",
+}
+await set_room_meta(store, room_id, room_meta)
+await chat_mgr.get_or_create(
+ user_id=room_meta["matrix_user_id"],
+ chat_id=room_meta["chat_id"],
+ platform="matrix",
+ surface_ref=room_id,
+ name=room_meta["display_name"],
+)
+```
+
+### Pattern 2: Per-Room `platform_chat_id` Is the Only Real Context Boundary
+**What:** Route every working Matrix room to its own durable `platform_chat_id`.
+**When to use:** Normal messaging, `!save`, `!load`, `!context`, `!clear`, restart restoration.
+**Example:**
+```python
+# Source: adapter/matrix/routed_platform.py + adapter/matrix/handlers/context_commands.py
+old_chat_id = room_meta["platform_chat_id"]
+new_chat_id = await next_platform_chat_id(store)
+await set_platform_chat_id(store, room_id, new_chat_id)
+
+disconnect = getattr(platform, "disconnect_chat", None)
+if callable(disconnect):
+ await disconnect(old_chat_id)
+```
+
+### Pattern 3: `!clear` Means Chat-ID Rotation, Not Global Wipe
+**What:** Implement real clear by rotating only the current room's `platform_chat_id` and disconnecting the old upstream chat session.
+**When to use:** User-triggered context reset for one room.
+**Example:**
+```python
+# Source: adapter/matrix/handlers/context_commands.py
+room_id = await _resolve_room_id(event, chat_mgr)
+old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id
+new_chat_id = await next_platform_chat_id(store)
+await set_platform_chat_id(store, room_id, new_chat_id)
+```
+
+### Pattern 4: Shared-Volume File Handoff Uses Relative Workspace Paths
+**What:** Persist incoming Matrix media into a room-scoped path under the shared workspace, and pass only relative paths to the agent.
+**When to use:** User uploads, staged attachments, agent-emitted files.
+**Example:**
+```python
+# Source: adapter/matrix/files.py
+relative_path = (
+ Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
+)
+return Attachment(
+ type=attachment.type,
+ url=attachment.url,
+ filename=filename,
+ mime_type=attachment.mime_type,
+ workspace_path=relative_path.as_posix(),
+)
+```
+
+### Pattern 5: Compose Split By Operational Intent
+**What:** Keep one compose artifact for operator handoff and one for internal full-stack testing.
+**When to use:** Deployment packaging.
+**Example:**
+```yaml
+# docker-compose.prod.yml
+services:
+ matrix-bot:
+ image: surfaces-bot:latest
+ env_file: .env
+ volumes:
+ - agents:/agents
+
+# docker-compose.fullstack.yml
+services:
+ matrix-bot:
+ extends:
+ file: docker-compose.prod.yml
+ service: matrix-bot
+ platform-agent:
+ ...
+volumes:
+ agents:
+```
+
+### Anti-Patterns to Avoid
+- **Lazy bootstrap as restart strategy:** `_bootstrap_unregistered_room()` is acceptable for first-contact repair, not as the primary restart recovery path in production.
+- **Per-user context identity:** a user-level or DM-level chat id breaks Space+rooms isolation and makes `!clear` incorrect.
+- **Global reset endpoint semantics:** `!clear` must not wipe other rooms or all agent state for a user.
+- **Absolute attachment paths in platform payloads:** keep agent attachment references relative to its workspace contract.
+- **Sleep-based service readiness:** use Compose healthchecks and dependency conditions, not shell `sleep`.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Matrix room/Space protocol | Raw custom HTTP wrappers for state events | `matrix-nio` `room_create`, `room_put_state`, `space_get_hierarchy`, `sync_forever`, `upload`, `download` | Official support already exists and repo tests are built around nio |
+| Restart topology discovery | Ad hoc timeline scraping | Full-state sync plus room state / Space child reconciliation | Timeline replay is noisy and brittle; state is the stable source |
+| File transfer bus | Base64 blobs or custom bot-side file API | Shared `/agents/` volume with relative `workspace_path` | Lower operational complexity and already matches upstream agent contract |
+| Compose startup sequencing | Shell loops / sleeps | `healthcheck` + `depends_on: condition: service_healthy` | Official Compose behavior is deterministic and observable |
+| Context reset | Deleting all SQLite rows or resetting the whole user | Rotate current room `platform_chat_id` and drop that room's live agent connection | Preserves other rooms and matches user expectation |
+
+**Key insight:** The deceptively hard problems in this phase are already solved by the current stack: Matrix room state, nio media handling, named volumes, and service health gating. Custom alternatives add more failure modes than value.
+
+## Common Pitfalls
+
+### Pitfall 1: Unknown room after restart creates a duplicate working chat
+**What goes wrong:** The bot treats an existing room as unregistered and provisions a fresh room/tree.
+**Why it happens:** Local SQLite metadata is missing, but Matrix topology still exists.
+**How to avoid:** Run reconciliation before live sync callbacks; only allow lazy bootstrap for genuinely new first-contact rooms.
+**Warning signs:** New `Чат N` rooms appear after restart without a matching user action.
+
+### Pitfall 2: `!clear` resets the wrong scope
+**What goes wrong:** Clearing one room also clears another room, or does nothing because the upstream session key did not change.
+**Why it happens:** Context is keyed by user or local `chat_id` instead of durable room-local `platform_chat_id`.
+**How to avoid:** Always resolve room -> `platform_chat_id`, rotate it, and disconnect only the old upstream chat.
+**Warning signs:** Two rooms share response history or `!context` reports the same platform context id.
+
+### Pitfall 3: Space child linkage is incomplete
+**What goes wrong:** Rooms exist but do not appear correctly under the user's Space.
+**Why it happens:** Missing or malformed `m.space.child` state, especially missing `via` data.
+**How to avoid:** Persist `space_id`, write `m.space.child` with `state_key=room_id`, and reconcile child links on startup.
+**Warning signs:** Element shows the room outside the Space, or not at all in the hierarchy.
+
+### Pitfall 4: Shared volume works locally but fails in deployment
+**What goes wrong:** Agent-generated files cannot be read by the bot, or bot-downloaded files are unreadable by the agent.
+**Why it happens:** Mount mismatch, wrong root (`/workspace` vs `/agents`), or container user/group permissions.
+**How to avoid:** Standardize one shared root, keep relative workspace paths, and align container permissions with Compose volume configuration.
+**Warning signs:** Attachment paths exist in metadata but not on disk inside the other container.
+
+### Pitfall 5: Compose `depends_on` starts too early
+**What goes wrong:** Bot starts before dependent services are actually ready.
+**Why it happens:** Short-form `depends_on` only waits for container start, not health.
+**How to avoid:** Use healthchecks and long-form `depends_on` with `service_healthy` in the full-stack compose file.
+**Warning signs:** First requests fail after fresh `docker compose up`, then succeed on retry.
+
+## Code Examples
+
+Verified patterns from official sources and current repo:
+
+### Create a Space with `matrix-nio`
+```python
+# Source: matrix-nio API docs
+space_resp = await client.room_create(
+ name=f"Lambda — {display_name}",
+ visibility=RoomVisibility.private,
+ invite=[matrix_user_id],
+ space=True,
+)
+```
+
+### Add a child room to a Space
+```python
+# Source: current repo pattern + Matrix spec
+await client.room_put_state(
+ room_id=space_id,
+ event_type="m.space.child",
+ content={"via": [homeserver]},
+ state_key=chat_room_id,
+)
+```
+
+### Persist room-scoped attachment paths
+```python
+# Source: adapter/matrix/files.py
+relative_path, absolute_path = build_workspace_attachment_path(
+ workspace_root=workspace_root,
+ matrix_user_id=matrix_user_id,
+ room_id=room_id,
+ filename=filename,
+)
+absolute_path.parent.mkdir(parents=True, exist_ok=True)
+absolute_path.write_bytes(body)
+```
+
+### Health-gated startup in Compose
+```yaml
+# Source: Docker Compose docs
+services:
+ matrix-bot:
+ depends_on:
+ platform-agent:
+ condition: service_healthy
+
+ platform-agent:
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Per-user or single shared platform context | Per-room `platform_chat_id` | Repo direction corrected on 2026-04-28 | Enables true room isolation and correct `!clear` |
+| Single overloaded compose runtime | Separate prod handoff and full-stack E2E compose files | Current Phase 05 scope | Reduces operator ambiguity |
+| Unknown room auto-bootstrap as recovery | Explicit reconciliation before live traffic | Recommended for Phase 05 | Prevents duplicate chat trees after restart |
+| File payloads treated as transport concern | Shared-volume relative path contract | Already present in repo | Keeps bot/platform contract simple and durable |
+
+**Deprecated/outdated:**
+- Single-chat / DM-first deployment direction: explicitly discarded in Phase 05 reset.
+- Global reset semantics for Matrix context commands: does not match Space+rooms UX.
+- Using only local store as truth for restart recovery: unsafe once deployed rooms outlive the process.
+
+## Open Questions
+
+1. **What exact Matrix state should reconciliation trust for `chat_id` labels?**
+ - What we know: `room_meta.chat_id` is local and not derivable from Matrix protocol by default.
+ - What's unclear: whether chat labels should be reconstructed from room names, stored custom state, or cached local metadata when present.
+ - Recommendation: persist `chat_id` in local SQLite, but make reconciliation able to regenerate a stable fallback label and avoid blocking routing if the label is missing.
+
+2. **What readiness probe exists for `platform-agent` in the full-stack compose?**
+ - What we know: Compose health gating is the right pattern.
+ - What's unclear: whether upstream agent image already exposes a reliable health endpoint.
+ - Recommendation: inspect upstream container and add a bot-facing probe before finalizing `docker-compose.fullstack.yml`.
+
+3. **Should prod mount root remain `/workspace` or be renamed to `/agents` externally?**
+ - What we know: current code defaults to `SURFACES_WORKSPACE_DIR=/workspace`, while deployment docs describe shared `/agents/`.
+ - What's unclear: whether external handoff wants a host path named `/agents` while containers still use `/workspace`.
+ - Recommendation: keep one in-container canonical path and let host-side naming vary only in Compose mounts.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Python | bot runtime | ✓ | 3.14.3 | — |
+| `uv` | dependency install | ✓ | 0.9.30 | `pip` |
+| `pytest` | validation | ✓ | 9.0.2 installed | `python -m pytest` |
+| Docker Engine | deployment packaging / E2E compose | ✓ | 29.1.3 | none |
+| Docker Compose | split runtime orchestration | ✓ | 2.40.3 | none |
+
+**Missing dependencies with no fallback:**
+- None
+
+**Missing dependencies with fallback:**
+- None
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | `pytest` + `pytest-asyncio` |
+| Config file | `pyproject.toml` |
+| Quick run command | `pytest tests/adapter/matrix/test_restart_persistence.py -v` |
+| Full suite command | `pytest tests/ -v` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| PH05-01 | Space+rooms onboarding remains primary UX | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py -v` | ✅ |
+| PH05-02 | Per-room `platform_chat_id` isolates routing and powers real clear | integration | `pytest tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_context_commands.py -v` | ✅ |
+| PH05-03 | Restart reconciliation restores routing metadata | integration | `pytest tests/adapter/matrix/test_restart_persistence.py -v` | ❌ new reconciliation tests needed |
+| PH05-04 | Shared-volume file transfer is room-safe | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial |
+| PH05-05 | Split prod/fullstack compose artifacts stay coherent | smoke | `docker compose -f docker-compose.prod.yml config && docker compose -f docker-compose.fullstack.yml config` | ❌ Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `pytest tests/adapter/matrix/test_restart_persistence.py -v`
+- **Per wave merge:** `pytest tests/adapter/matrix/ -v`
+- **Phase gate:** `pytest tests/ -v` plus both compose files passing `docker compose ... config`
+
+### Wave 0 Gaps
+- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user/room metadata from Matrix state
+- [ ] `tests/adapter/matrix/test_context_commands.py` additions — `!clear` command contract and room-local rotation semantics
+- [ ] `tests/adapter/matrix/test_compose_artifacts.py` or equivalent smoke command documentation — split compose validation
+- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment path isolation and shared-root consistency
+
+## Sources
+
+### Primary (HIGH confidence)
+- Local repo code and tests:
+ - `adapter/matrix/bot.py`
+ - `adapter/matrix/store.py`
+ - `adapter/matrix/files.py`
+ - `adapter/matrix/routed_platform.py`
+ - `adapter/matrix/handlers/auth.py`
+ - `adapter/matrix/handlers/context_commands.py`
+ - `tests/adapter/matrix/test_restart_persistence.py`
+ - `tests/adapter/matrix/test_files.py`
+ - `tests/platform/test_real.py`
+- Matrix-nio API docs: https://matrix-nio.readthedocs.io/en/latest/nio.html
+- Matrix-nio async client docs: https://matrix-nio.readthedocs.io/en/latest/_modules/nio/client/async_client.html
+- Matrix-nio PyPI release page: https://pypi.org/project/matrix-nio/
+- Matrix spec Spaces / hierarchy: https://spec.matrix.org/v1.18/server-server-api/
+- Matrix spec changelog note on `via` for `m.space.child`: https://spec.matrix.org/v1.16/changelog/v1.9/
+- Docker Compose CLI reference: https://docs.docker.com/reference/cli/docker/compose/
+- Docker Compose services reference: https://docs.docker.com/reference/compose-file/services/
+
+### Secondary (MEDIUM confidence)
+- `docs/deploy-architecture.md` — repo-local deployment contract clarified on 2026-04-27
+- `docs/research/matrix-spaces.md` — prior internal research aligned with spec, but not treated as primary
+- `README.md` runtime notes for current Matrix backend and shared workspace behavior
+
+### Tertiary (LOW confidence)
+- None
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - current repo stack verified against official docs and package registries
+- Architecture: HIGH - recommendations align with existing runtime boundaries and official Matrix / Compose behavior
+- Pitfalls: HIGH - derived from current code paths, existing tests, and official protocol/runtime semantics
+
+**Research date:** 2026-04-28
+**Valid until:** 2026-05-28
diff --git a/.planning/phases/05-mvp-deployment/05-VALIDATION.md b/.planning/phases/05-mvp-deployment/05-VALIDATION.md
new file mode 100644
index 0000000..6466df9
--- /dev/null
+++ b/.planning/phases/05-mvp-deployment/05-VALIDATION.md
@@ -0,0 +1,83 @@
+---
+phase: 05
+slug: mvp-deployment
+status: revised
+nyquist_compliant: true
+wave_0_complete: false
+created: 2026-04-28
+---
+
+# Phase 05 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | `pytest` + `pytest-asyncio` |
+| **Config file** | `pyproject.toml` |
+| **Quick run command** | `pytest tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` |
+| **Full suite command** | `pytest tests/ -v` |
+| **Estimated runtime** | targeted slices < 60 seconds each; full suite longer |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run the exact `` command from the task that just changed
+- **After every plan wave:** Run `pytest tests/adapter/matrix/ -v`
+- **Before `$gsd-verify-work`:** Full suite must be green
+- **Max feedback latency:** 60 seconds for task-level slices
+
+---
+
+## Per-Task Verification Map
+
+| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|-----------|-------------------|-------------|--------|
+| 05-01-01 | 01 | 1 | PH05-01 | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` | ❌ W0 | ⬜ pending |
+| 05-01-02 | 01 | 1 | PH05-03 | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v` | ❌ W0 | ⬜ pending |
+| 05-02-01 | 02 | 2 | PH05-02 | integration | `pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v` | ✅ partial | ⬜ pending |
+| 05-02-02 | 02 | 2 | PH05-02 | integration | `pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v` | ✅ partial | ⬜ pending |
+| 05-03-01 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial | ⬜ pending |
+| 05-03-02 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v` | ✅ partial | ⬜ pending |
+| 05-04-01 | 04 | 2 | PH05-05 | smoke | `docker compose -f docker-compose.prod.yml config && docker compose -f docker-compose.fullstack.yml config` | ❌ W0 | ⬜ pending |
+| 05-04-02 | 04 | 2 | PH05-05 | docs smoke | `rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example` | ✅ | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user and room metadata from Matrix state
+- [ ] `tests/adapter/matrix/test_restart_persistence.py` additions — deterministic backfill for legacy rooms missing `platform_chat_id`
+- [ ] `tests/adapter/matrix/test_context_commands.py` additions — room-local `!clear` rotation semantics
+- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment isolation and shared-root consistency
+- [ ] Compose smoke coverage or documented verification command for `docker-compose.prod.yml` and `docker-compose.fullstack.yml`
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Restart after real Matrix room topology exists | PH05-03 | Full recovery depends on live Space hierarchy and persisted homeserver state | Start the bot, provision a Space and chat rooms, stop the bot, remove local SQLite metadata, restart, confirm routing and room labels are rebuilt before live messages are handled |
+| Shared `/agents` volume behavior across bot and platform containers | PH05-04 | Container mounts and permissions are environment-dependent | Run `docker compose -f docker-compose.fullstack.yml up`, upload a file in Matrix, confirm the agent sees the relative `workspace_path`, then confirm an agent-created file is readable back from the bot side |
+| Operator handoff of prod compose | PH05-05 | Final deploy contract depends on real env files and target host conventions | Run `docker compose -f docker-compose.prod.yml config` on the target deployment checkout and confirm only bot services, required env vars, and shared volumes are present |
+
+---
+
+## Validation Sign-Off
+
+- [ ] All tasks have `` verify or Wave 0 dependencies
+- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
+- [ ] Wave 0 covers all MISSING references
+- [ ] No watch-mode flags
+- [x] Feedback latency target tightened to task slices under 60s
+- [x] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending
diff --git a/.planning/reports/20260422-session-report.md b/.planning/reports/20260422-session-report.md
new file mode 100644
index 0000000..9044d2b
--- /dev/null
+++ b/.planning/reports/20260422-session-report.md
@@ -0,0 +1,92 @@
+# GSD Session Report
+
+**Generated:** 2026-04-21T22:33:11.666Z
+**Project:** surfaces-bot
+**Milestone:** v1.0 — Production-ready surfaces
+
+---
+
+## Session Summary
+
+**Duration:** Single session
+**Phase Progress:** Phase 04 implemented; current follow-up work is audit, stabilization, and platform bug localization
+**Plans Executed:** 0 formal GSD plans executed in this session; work was focused on post-implementation audit and cleanup
+**Commits Made:** 6
+
+## Work Performed
+
+### Phases Touched
+
+- **Phase 04** — Matrix MVP follow-up after implementation:
+ - completed audit of platform patches vs surface-owned responsibilities
+ - removed dependence on local platform modifications for `chat_id`
+ - switched Matrix integration to numeric `platform_chat_id` mapping on our side
+ - cleaned transport layer to a thin adapter over upstream `AgentApi`
+ - updated README and run instructions
+ - produced final Russian bug report with raw-trace-based diagnosis
+
+### Key Outcomes
+
+- Platform repos are clean and synced to pinned upstream commits.
+- Matrix real backend works with numeric surrogate `platform_chat_id`.
+- `surfaces` transport layer no longer owns custom stream semantics.
+- Final diagnosis was narrowed: missing-first-chunk bug is now considered platform-side with direct raw evidence.
+- Working state was committed and pushed on `feat/matrix-direct-agent-prototype`.
+
+### Decisions Made
+
+- Do not patch vendored platform repos for the working implementation.
+- Keep `surfaces` transport layer thin and upstream-aligned.
+- Treat the current streaming bug as platform-side unless new evidence disproves it.
+- Do not add new local stream workarounds that would blur responsibility.
+
+## Files Changed
+
+- `README.md`
+- `adapter/matrix/bot.py`
+- `sdk/agent_api_wrapper.py`
+- `sdk/real.py`
+- `tests/platform/test_real.py`
+- `tests/adapter/matrix/test_dispatcher.py`
+- `tests/core/test_integration.py`
+- `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`
+
+Planning / handoff artifacts updated:
+
+- `.planning/HANDOFF.json`
+- `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.continue-here.md`
+- `.planning/reports/20260422-session-report.md`
+
+## Blockers & Open Items
+
+- Platform-side streaming bug after tool/file flow.
+- Duplicate `END` from platform.
+- Image path failure on oversized `data:` URI.
+- `tokens_used` remains unavailable from pinned upstream client.
+
+## Estimated Resource Usage
+
+| Metric | Estimate |
+|--------|----------|
+| Commits | 6 |
+| Files changed | 8 code/docs files in the main deliverable, plus planning artifacts |
+| Plans executed | 0 formal plans in this session |
+| Subagents spawned | 0 |
+
+> **Note:** Token and cost estimates require API-level instrumentation.
+> These metrics reflect observable session activity only.
+
+---
+
+### Recent Commits
+
+- `0c2884c` — `refactor: use thin upstream transport adapter`
+- `569824e` — `refactor: shrink agent api wrapper to thin adapter`
+- `4d917ac` — `docs: add thin transport adapter plan`
+- `3a3fcdc` — `docs: add thin transport adapter design`
+- `7a2ad86` — `docs: clarify matrix file sending flow`
+- `4524a6a` — `feat: finalize matrix platform audit and docs`
+
+---
+
+*Generated by `$gsd-session-report`*
diff --git a/.planning/threads/matrix-dev-prototype-agent-platform-state.md b/.planning/threads/matrix-dev-prototype-agent-platform-state.md
new file mode 100644
index 0000000..facd575
--- /dev/null
+++ b/.planning/threads/matrix-dev-prototype-agent-platform-state.md
@@ -0,0 +1,133 @@
+# Thread: Matrix dev prototype — состояние агента и платформы
+
+## Status: IN PROGRESS
+
+## Goal
+
+Зафиксировать текущее состояние платформы для последующей разработки Matrix dev прототипа,
+в котором команды разработки скиллов смогут быстро добавлять и обкатывать скиллы.
+
+## Context
+
+*Исследование проведено 2026-04-14. Репозитории: `external/platform-agent`, `external/platform-agent_api`, `external/platform-master`.*
+
+### Решение по деплою: локальный контейнер у каждого разработчика
+
+`platform-master` не готов для общего деплоя:
+- lifecycle management контейнеров (TTL, cleanup, переиспользование сессий) — в ветке `feat/storage`, не смержено в main
+- без него при общем деплое контейнеры висят вечно, ресурсы не освобождаются
+
+Локальный вариант: `make up-dev` — полностью рабочий, volume mount `./workspace:/workspace/`, hot reload src.
+
+### Архитектура изоляции контекстов
+
+`AgentService` — singleton с `thread_id = "default"` — это **намеренно**. Архитектура Master предполагает один контейнер `platform-agent` на один чат. Изоляция на уровне контейнеров, не thread_id. Фиксить не нужно.
+
+### Система скиллов (deepagents)
+
+`SkillsMiddleware` в `deepagents` полностью готов:
+- скилл = директория с `SKILL.md` (YAML frontmatter + markdown инструкции)
+- progressive disclosure: агент видит имя+описание в system prompt, читает полный файл по требованию
+- загружается один раз при старте сессии, кэшируется в LangGraph state
+
+**НЕ подключено** в `platform-agent/src/agent/base.py` — отсутствует одна строка:
+```python
+skills=["/workspace/skills/"]
+```
+Это задача для команды платформы.
+
+### Workflow разработчика скилла
+
+```
+workspace/
+ skills/
+ my-skill/
+ SKILL.md ← редактируешь здесь (live через volume mount)
+ helper.py ← вспомогательные файлы
+ config/
+ my-skill.json ← токены и настройки (пишет агент при первом запуске)
+```
+
+1. Редактируешь `SKILL.md`
+2. `!new` в Matrix (новая сессия = скиллы перечитываются)
+3. Проверяешь поведение
+4. Повторяешь
+
+Агент может **сам установить скилл** из GitHub:
+- `execute` → git clone
+- `write_file` → положить в `/workspace/skills/`
+- после `!new` скилл активен
+
+### Конфигурация скиллов (токены, API ключи)
+
+Агент управляет конфигом сам:
+- первый запуск: спрашивает пользователя → пишет в `/workspace/config/skill-name.json`
+- последующие запуски: читает из файла
+- файл персистентен между сессиями (volume mount)
+
+### Входящий протокол (что принимает агент)
+
+`ClientMessage` — только `text: str`. Файлы и изображения не поддерживаются.
+Задача для платформы — расширить протокол.
+
+### Исходящий протокол (что шлёт агент)
+
+Новые события с `origin/main` (апрель 2026):
+- `AGENT_EVENT_TOOL_CALL_CHUNK` — агент вызывает инструмент
+- `AGENT_EVENT_TOOL_RESULT` — результат инструмента
+- `AGENT_EVENT_CUSTOM_UPDATE` — произвольный прогресс
+
+**Наш `sdk/agent_session.py` падает на этих событиях** (`raise PlatformError("Unexpected agent message")`).
+Нужно починить — это наша задача, ~10 строк.
+
+### AgentApi из lambda_agent_api
+
+Готовый production-клиент с правильным lifecycle (`connect()`, `close()`, `send_message()` как `AsyncIterator`).
+Наш `sdk/agent_session.py` дублирует его функциональность. Стоит заменить.
+
+### Инструменты агента из коробки
+
+- `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` — файловые операции в workspace
+- `execute` — shell под изолированным OS-пользователем `agent`
+- `write_todos` — список задач
+- `task` — вызов субагентов
+
+### Запуск локально
+
+```bash
+# .env минимально необходимый:
+PROVIDER_URL=https://openrouter.ai/api/v1
+PROVIDER_API_KEY=<ключ>
+PROVIDER_MODEL=anthropic/claude-sonnet-4-6
+
+# Dev контейнер:
+make up-dev # требует AGENT_API_PATH=../platform-agent_api в env
+```
+
+Dev Dockerfile монтирует `./workspace:/workspace/` и `./src:/app/src` (hot reload).
+
+## Что нужно от платформы
+
+1. Добавить `skills=["/workspace/skills/"]` в `platform-agent/src/agent/base.py`
+2. Поддержка файлов/изображений в `ClientMessage` (не срочно для MVP)
+3. Lifecycle management контейнеров в Master (для общего деплоя, не срочно)
+
+## Что делаем мы
+
+1. Починить `sdk/agent_session.py` — обработка tool-событий вместо исключения
+2. (опционально) Заменить `AgentSessionClient` на `AgentApi` из `lambda_agent_api`
+
+## References
+
+- `external/platform-agent` — локальный клон, наш патч `1dca2c1` (thread_id) поверх `1e9fa1f`
+- `external/platform-agent_api` — локальный клон, актуальный (origin/master = `bb20a84`)
+- `external/platform-master` — локальный клон, активная разработка в `feat/storage-s02`
+- `docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md`
+- `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`
+
+## Next Steps
+
+1. Запросить у команды платформы: подключение `SkillsMiddleware` в `base.py`
+2. Починить `sdk/agent_session.py` — обработать tool-события
+3. Написать первый тестовый скилл (`workspace/skills/hello/SKILL.md`) и проверить end-to-end
+4. Документировать workflow для разработчиков скиллов
diff --git a/.planning/threads/matrix-file-ingestion-context.md b/.planning/threads/matrix-file-ingestion-context.md
new file mode 100644
index 0000000..0ccb079
--- /dev/null
+++ b/.planning/threads/matrix-file-ingestion-context.md
@@ -0,0 +1,81 @@
+# Thread: Matrix file ingestion and agent-visible storage contract
+
+## Status: IN PROGRESS
+
+## Goal
+
+Сохранить текущий контекст сессии для следующего агента и зафиксировать следующую архитектурную развилку: как принимать вложения из Matrix и делать их доступными агенту.
+
+## Current State
+
+Phase 4 Matrix MVP уже собран и проверен на уровне per-room routing:
+- обычные сообщения теперь идут в `platform_chat_id`, а не в общий локальный `C1/C2`
+- `!context` показывает состояние текущего Matrix-чата
+- `!save` и `!load` привязаны к текущему room-context
+- `PrototypeStateStore` хранит live state per context
+- последние изменения закоммичены в `feat/matrix-direct-agent-prototype`
+
+Коммиты, которые важно знать:
+- `c11c8ec` `feat(task-5): scope matrix context state per room`
+- `07c5078` `feat(task-7): verify matrix per-room context routing`
+
+## What We Learned About Platform Runtime
+
+Текущий `external/platform-agent` не является отдельным контейнером на чат.
+Фактическая модель сейчас такая:
+- один FastAPI-процесс
+- singleton `AgentService`
+- `thread_id` используется как ключ памяти в LangGraph, а не как контейнерная изоляция
+- файловой изоляции на чат сейчас нет
+- `/workspace` как общий mount для Matrix bot и platform-agent сейчас не настроен
+- отдельного upload API для вложений в текущем коде не видно
+
+Ключевые файлы:
+- `external/platform-agent/src/api/external.py`
+- `external/platform-agent/src/agent/service.py`
+- `external/platform-agent/src/agent/base.py`
+
+## File Handling Requirement
+
+Пользовательский запрос на текущем этапе:
+- принимать файл или сообщение с файлом из Matrix
+- сохранять файл локально
+- передавать агенту явный сигнал, что к сообщению есть вложения
+- сообщать, где лежит файл
+
+Но есть техническое ограничение:
+- если Matrix bot пишет файл только в своём контейнере, platform-agent его не увидит
+- значит нужен либо общий storage, либо upload в платформу, либо контейнеризация platform-agent с общим volume
+
+## Recommended Design Direction
+
+Самый прагматичный MVP-вариант:
+- хранить вложения в общем каталоге, который виден и Matrix bot, и platform-agent
+- формировать для агента структурированный payload с:
+ - локальным путём
+ - original filename
+ - mime type
+ - attachment type
+- если есть текст пользователя, дополнять сообщение краткой summary-подсказкой про вложения
+- если прислан только файл, отправлять synthetic message вроде “пользователь прислал файл”
+
+Если общий каталог невозможен в текущем runtime:
+- следующий вариант это upload endpoint в platform-agent
+- Matrix surface скачивает файл и загружает его в платформу, а платформа уже кладёт его в своё доступное хранилище
+
+## Open Questions
+
+1. Где должен жить shared storage: host path, docker volume или platform-side volume?
+2. Нужен ли немедленный upload API в platform-agent, или сначала достаточно shared path?
+3. Должны ли файлы быть scoped per room/platform_chat_id, а не per user?
+
+## Next Step For Another Agent
+
+1. Подтвердить runtime-модель хранения файлов.
+2. Проверить, как сейчас запускаются Matrix bot и platform-agent в реальной dev-схеме.
+3. После выбора storage contract начать с изменений в Matrix attachment ingestion.
+
+## Notes
+
+- Контекст этой сессии сохранён как отдельный thread, потому что текущий следующий рискованный шаг уже не про context routing, а про файловый transport.
+- Не смешивать этот трек с незавершённой историей про `!branch`: upstream branch/snapshot API всё ещё не подтверждён.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c04d98a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+FROM python:3.11-slim AS base
+
+WORKDIR /app
+RUN useradd -u 1000 -m appuser
+USER appuser
+
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONPATH=/app
+ENV UV_PROJECT_ENVIRONMENT=/usr/local
+
+# Install uv and git for reproducible platform SDK installation.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates git \
+ && rm -rf /var/lib/apt/lists/* \
+ && pip install --no-cache-dir uv
+
+# Copy dependency manifests first for layer caching.
+COPY pyproject.toml uv.lock* ./
+
+# Install project dependencies into the system environment.
+RUN uv sync --no-dev --no-install-project --frozen
+
+FROM base AS development
+
+COPY . .
+RUN uv sync --no-dev --frozen
+
+# Local fullstack/dev builds can override the SDK with a checked-out agent_api
+# build context, matching platform-agent's development Dockerfile pattern.
+COPY --from=agent_api . /agent_api/
+RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/
+
+CMD ["python", "-m", "adapter.matrix.bot"]
+
+FROM base AS production
+
+COPY . .
+RUN uv sync --no-dev --frozen
+
+# Production builds follow the platform-agent pattern: install the API SDK from
+# the platform Git repository instead of relying on local external/ clones.
+ARG LAMBDA_AGENT_API_REF=master
+RUN python -m pip install --no-cache-dir --ignore-requires-python \
+ "git+https://git.lambda.coredump.ru/platform/agent_api.git@${LAMBDA_AGENT_API_REF}"
+
+CMD ["python", "-m", "adapter.matrix.bot"]
diff --git a/README.md b/README.md
index 318a45d..3b8a7a6 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,54 @@
# Lambda Lab 3.0 — Surfaces
-Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda.
+Matrix-бот для взаимодействия пользователя с AI-агентом Lambda.
-## Статус
+## Интеграция для платформы
-Прототип в разработке. SDK платформы ещё не готов — работаем через `MockPlatformClient`.
+Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. Production target — один surface container на 25-30 внешних agent containers/services.
-| Поверхность | Статус | Описание |
-|---|---|---|
-| Telegram | 🔨 В разработке | DM + Forum Topics mode, активная реализация сейчас в отдельном worktree |
-| Matrix | 🔨 В разработке | Незашифрованные комнаты: бот создаёт private Space и отдельную room на каждый чат |
+### Что бот ожидает от вас
+
+**1. HTTP-эндпоинт агента**
+Бот подключается к агенту через `lambda_agent_api.AgentApi` по адресу `AGENT_BASE_URL`.
+Протокол — WebSocket поверх HTTP, контракт описан в `docs/deploy-architecture.md`.
+
+**2. Shared volume с per-agent поддиректориями**
+Shared volume монтируется в бот как `/agents`. Каждый агент видит свою поддиректорию.
+
+```
+Bot container Agent containers
+ /agents/0/ ←── volume ──→ agent_0: /workspace/
+ /agents/1/ ←── volume ──→ agent_1: /workspace/
+ /agents/N/ ←── volume ──→ agent_N: /workspace/
+```
+
+- Бот сохраняет входящий файл прямо в `{workspace_path}/{file}` и передаёт агенту `attachments=["{file}"]`
+- Если файл с таким именем уже есть, бот сохраняет следующий как `file (1).ext`, `file (2).ext`, как в Windows
+- Агент пишет исходящий файл прямо в свой `/workspace/file`, бот читает его из `{workspace_path}/file`
+- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
+
+**3. Конфиг агентов**
+Файл `config/matrix-agents.yaml` — маппинг Matrix-пользователей на агентов. Вы заполняете его под свою инфраструктуру. Пример в `config/matrix-agents.example.yaml`.
+
+### Что бот не делает
+
+- Не управляет lifecycle агент-контейнеров (запуск/остановка — на вашей стороне)
+- Не хранит историю разговоров (это в памяти агента)
+- Не обрабатывает аутентификацию пользователей — любой Matrix-пользователь, который пишет боту, получает доступ
+
+### Минимальный чеклист
+
+- [ ] Взять опубликованный image `SURFACES_BOT_IMAGE` или собрать production image из этого репозитория
+- [ ] Заполнить `config/matrix-agents.yaml` — ID агентов, `base_url` каждого, `workspace_path`, маппинг пользователей
+- [ ] Задать переменные окружения (см. `.env.example`): Matrix credentials, `MATRIX_PLATFORM_BACKEND=real`, `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml`
+- [ ] Смонтировать в бот-контейнер shared volume как `/agents` — каждый агент должен видеть свою поддиректорию как `/workspace`
+- [ ] Добавить bot-only service в свой compose; агенты в этот compose не входят и управляются платформой
---
-## Концепция
+## Статус
-Пользователь получает персонального AI-агента через привычный мессенджер.
-Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём.
-
-**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы.
-Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым.
+Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff.
---
@@ -30,121 +59,223 @@ surfaces-bot/
core/ — общее ядро, не зависит от транспорта
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
- handlers/ — обработчики по типам событий
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
- chat.py — ChatManager: метаданные чатов C1/C2/C3
- auth.py — AuthManager: аутентификация
- settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность
+ chat.py — ChatManager
+ auth.py — AuthManager
+ settings.py — SettingsManager
adapter/
- telegram/ — aiogram 3.x адаптер
matrix/ — matrix-nio адаптер
sdk/
interface.py — PlatformClient Protocol (контракт к SDK)
- mock.py — MockPlatformClient (заглушка)
+ real.py — RealPlatformClient (через AgentApi)
+ mock.py — MockPlatformClient (заглушка для тестов)
+
+ config/
+ matrix-agents.yaml — реестр агентов
docs/ — документация
- .claude/agents/ — агенты для Claude Code
```
-**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер.
-Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md)
+Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md)
---
-## Функционал прототипа
+## Деплой
-### Telegram ([подробнее](docs/telegram-prototype.md))
-
-- **Чаты** — основной Telegram UX сейчас развивается в отдельном worktree `feat/telegram-adapter`
-- **Forum Topics mode** — бот умеет подключать forum-группу через `/forum`; чат может быть привязан к отдельной теме
-- **DM-режим** — базовый диалог и переключение чатов сохраняются
-- **Аутентификация** — привязка Telegram аккаунта к аккаунту платформы
-- **Диалог** — typing indicator, передача файлов, подтверждение опасных действий через inline-кнопки
-- **Настройки** через `/settings`: коннекторы (Gmail, GitHub, Notion...), скиллы, личность агента (SOUL), безопасность, подписка
-
-### Matrix ([подробнее](docs/matrix-prototype.md))
-
-- **Онбординг** — при первом invite бот создаёт private Space `Lambda — {display_name}` и первую комнату `Чат 1`, сразу приглашая туда пользователя
-- **Чаты** — `!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager`
-- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher`
-- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
-- **Текущее ограничение** — encrypted DM пока не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
-
----
-
-## Замена SDK
-
-Вся работа с платформой идёт через `PlatformClient` Protocol:
-
-```python
-class PlatformClient(Protocol):
- async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ...
- async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ...
- async def get_settings(self, user_id: str) -> UserSettings: ...
- async def update_settings(self, user_id: str, action: Any) -> None: ...
-```
-
-Бот не управляет lifecycle контейнеров — это делает Master (платформа).
-Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер.
-
-Сейчас: `MockPlatformClient` в `sdk/mock.py`.
-Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем.
-
----
-
-## Быстрый старт
+### Переменные окружения
```bash
-# Зависимости
-uv sync # или: pip install -e ".[dev]"
+cp .env.example .env
+```
-# Тесты
+| Переменная | Обязательна | Описание |
+|---|---|---|
+| `MATRIX_HOMESERVER` | ✓ | URL Matrix-сервера |
+| `MATRIX_USER_ID` | ✓ | `@bot:example.org` |
+| `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) |
+| `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна |
+| `SURFACES_BOT_IMAGE` | ✓ | Docker image поверхности: `mput1/surfaces-bot:latest` |
+| `AGENT_BASE_URL` | | Fallback URL агента если `base_url` не задан в `matrix-agents.yaml` |
+| `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` |
+| `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) |
+| `SURFACES_SHARED_VOLUME` | | имя Docker volume (по умолчанию `surfaces-agents`) |
+
+### Реестр агентов
+
+`config/matrix-agents.yaml` — статический маппинг пользователей на агентов:
+
+```yaml
+user_agents:
+ "@user0:matrix.lambda.coredump.ru": agent-0
+ "@user1:matrix.lambda.coredump.ru": agent-1
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+ - id: agent-1
+ label: "Agent 1"
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
+ - id: agent-2
+ label: "Agent 2"
+ base_url: "http://lambda.coredump.ru:7000/agent_2/"
+ workspace_path: "/agents/2"
+```
+
+- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент.
+- `base_url` — HTTP URL агент-эндпоинта (path-based routing через reverse proxy).
+- `workspace_path` — путь к воркспейсу агента внутри бот-контейнера на shared volume.
+ Бот сохраняет входящие файлы прямо в `{workspace_path}/`, агент пишет исходящие прямо в свой `/workspace/`.
+- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
+
+Полный пример с комментариями: `config/matrix-agents.example.yaml`
+
+### Production (bot-only)
+
+`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры.
+
+Перед redeploy можно проверить реальные agent routes из той же сети, где будет работать бот:
+```bash
+PYTHONPATH=. uv run python -m tools.check_matrix_agents \
+ --config config/matrix-agents.yaml \
+ --timeout 5
+```
+
+Проверка открывает фактический WebSocket URL каждого агента (`.../v1/agent_ws/{chat_id}/`) и ждёт первый `STATUS`. Для проверки полного запроса к агенту добавьте `--message "ping"`.
+
+Для запуска опубликованного image:
+```bash
+export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
+docker compose --env-file .env -f docker-compose.prod.yml up -d
+```
+
+Опубликованный image:
+
+```text
+mput1/surfaces-bot:latest
+sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
+```
+
+Для сборки и публикации surface image:
+```bash
+docker login
+export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
+
+docker build --target production \
+ --build-arg LAMBDA_AGENT_API_REF=master \
+ -t "$SURFACES_BOT_IMAGE" .
+docker push "$SURFACES_BOT_IMAGE"
+```
+
+Если push возвращает `insufficient_scope`, текущий Docker login не имеет доступа к namespace/repository. Создайте repository в нужном Docker Hub namespace или перетегируйте image в namespace пользователя, под которым выполнен `docker login`.
+
+### Fullstack E2E (bot + agent)
+
+```bash
+docker compose --env-file .env -f docker-compose.fullstack.yml up --build
+```
+
+Поднимает `matrix-bot` вместе с локальным `platform-agent`. Это internal harness, не production topology на 25-30 агентов. `matrix-bot` собирается через Dockerfile target `development` и локальный `external/platform-agent_api` context, как в dev-сборке `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте.
+
+### Сброс состояния (локально)
+
+```bash
+rm -f lambda_matrix.db && rm -rf matrix_store
+```
+
+---
+
+## Shared volume: передача файлов
+
+```
+Bot (/agents) Agent (/workspace = /agents/N/)
+ /agents/0/report.pdf ←──── одно и то же хранилище ────→ /workspace/report.pdf
+ /agents/0/result.txt ←────────────────────────────────→ /workspace/result.txt
+```
+
+- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/{file}`, например `/agents/17/report.pdf`, и передаёт агенту `attachments=["report.pdf"]`
+- **Коллизии имён**: если `/agents/17/report.pdf` уже существует, бот сохранит следующий файл как `/agents/17/report (1).pdf`, затем `/agents/17/report (2).pdf`
+- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/file`, бот читает из `{workspace_path}/file`, например `/agents/17/result.txt`, и отправляет пользователю как Matrix file message
+- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml`
+
+---
+
+## Онбординг пользователя
+
+1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере
+2. Бот создаёт private Space `Lambda — {display_name}` и комнату `Чат 1`
+3. Дальнейшее общение — в рабочих комнатах, не в DM
+
+**Требование:** незашифрованные комнаты. E2EE не поддержан.
+
+---
+
+## Команды Matrix
+
+### Работающие
+
+| Команда | Действие |
+|---|---|
+| *(любое сообщение)* | Диалог с агентом, стриминг ответа |
+| `!new [название]` | Создать новый чат |
+| `!chats` | Список активных чатов |
+| `!rename <название>` | Переименовать текущую комнату |
+| `!archive` | Архивировать чат |
+| `!clear` | Сбросить контекст текущего чата |
+| `!yes` / `!no` | Подтвердить / отменить действие агента |
+| `!list` | Файлы в очереди вложений |
+| `!remove ` / `!remove all` | Удалить вложение из очереди |
+| `!help` | Справка |
+
+### Не работают / заглушки
+
+| Команда | Статус |
+|---|---|
+| `!save` / `!load` / `!context` | Нестабильны: зависят от агента, сессии теряются при рестарте |
+| `!settings` и подкоманды | Заглушки MVP, требуют готового SDK платформы |
+
+---
+
+## Отправка файлов агенту
+
+Matrix-клиент отправляет файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь.
+
+```
+[отправил файл]
+!list
+ 1. report.pdf
+
+прочитай и сделай summary ← файл уйдёт агенту вместе с этим текстом
+```
+
+---
+
+## Известные ограничения
+
+| Проблема | Причина |
+|---|---|
+| История теряется при рестарте агента | `platform-agent` использует `MemorySaver` (in-memory) |
+| E2EE | `python-olm` не собирается на macOS/ARM |
+
+---
+
+## Разработка
+
+```bash
+uv sync
pytest tests/ -v
-
-# Запустить Matrix бота
-cp .env.example .env # заполнить MATRIX_* переменные
-PYTHONPATH=. uv run python -m adapter.matrix.bot
+pytest tests/adapter/matrix/ -v # только Matrix
```
-### Telegram worktree
-
-Текущая Telegram-разработка идёт в отдельном worktree:
-
-```bash
-cd .worktrees/telegram
-export BOT_TOKEN=...
-PYTHONPATH=. python -m adapter.telegram.bot
-```
-
-### Matrix manual QA
-
-Пока Matrix-бот тестируется в незашифрованных комнатах:
-
-```bash
-cd /path/to/surfaces-bot
-rm -f lambda_matrix.db
-rm -rf matrix_store
-PYTHONPATH=. uv run python -m adapter.matrix.bot
-```
-
----
-
## Документация
| Файл | Содержание |
|---|---|
-| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Унификация поверхностей — все структуры, как добавить новую поверхность |
-| [`docs/telegram-prototype.md`](docs/telegram-prototype.md) | Функционал Telegram прототипа |
-| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Функционал Matrix прототипа |
-| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы |
-| [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey |
-| [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code |
-
----
-
-## Команда
-
-Поверхности и интеграции
-Lambda Lab 3.0, МАИ
+| [`docs/deploy-architecture.md`](docs/deploy-architecture.md) | **Читать первым.** Топология деплоя, volume-контракт, AgentApi, конфигурация |
+| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов |
+| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути |
+| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) |
diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py
new file mode 100644
index 0000000..bf02018
--- /dev/null
+++ b/adapter/matrix/agent_registry.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Literal
+
+import yaml
+
+
+class AgentRegistryError(ValueError):
+ pass
+
+
+@dataclass(frozen=True)
+class AgentDefinition:
+ agent_id: str
+ label: str
+ base_url: str = field(default="")
+ workspace_path: str = field(default="")
+
+
+@dataclass(frozen=True)
+class AgentAssignment:
+ agent_id: str | None
+ source: Literal["configured", "default", "none"]
+
+ @property
+ def is_default(self) -> bool:
+ return self.source == "default"
+
+
+class AgentRegistry:
+ def __init__(
+ self,
+ agents: list[AgentDefinition],
+ user_agents: Mapping[str, str] | None = None,
+ ) -> None:
+ self.agents = tuple(agents)
+ self._by_id = {agent.agent_id: agent for agent in self.agents}
+ self._user_agents: dict[str, str] = dict(user_agents or {})
+
+ def get(self, agent_id: str) -> AgentDefinition:
+ try:
+ return self._by_id[agent_id]
+ except KeyError as exc:
+ raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
+
+ def get_agent_id_for_user(self, matrix_user_id: str) -> str | None:
+ return self._user_agents.get(matrix_user_id)
+
+ def resolve_agent_for_user(self, matrix_user_id: str) -> AgentAssignment:
+ agent_id = self.get_agent_id_for_user(matrix_user_id)
+ if agent_id is not None:
+ return AgentAssignment(agent_id=agent_id, source="configured")
+ if self.agents:
+ return AgentAssignment(agent_id=self.agents[0].agent_id, source="default")
+ return AgentAssignment(agent_id=None, source="none")
+
+
+def _required_text(entry: Mapping[str, object], key: str) -> str:
+ value = entry.get(key)
+ if not isinstance(value, str):
+ raise AgentRegistryError("each agent entry requires id and label")
+ text = value.strip()
+ if not text:
+ raise AgentRegistryError("each agent entry requires id and label")
+ return text
+
+
+def _optional_text(entry: Mapping[str, object], key: str) -> str:
+ value = entry.get(key)
+ if value is None:
+ return ""
+ if not isinstance(value, str):
+ raise AgentRegistryError(f"agent entry field '{key}' must be a string")
+ return value.strip()
+
+
+def _load_registry_data(path: str | Path) -> dict[str, object]:
+ try:
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
+ except yaml.YAMLError as exc:
+ raise AgentRegistryError("invalid agent registry YAML") from exc
+ if raw is None:
+ return {}
+ if not isinstance(raw, Mapping):
+ raise AgentRegistryError("agent registry must be a mapping with an agents list")
+ return dict(raw)
+
+
+def load_agent_registry(path: str | Path) -> AgentRegistry:
+ raw = _load_registry_data(path)
+ entries = raw.get("agents")
+ if not isinstance(entries, list) or not entries:
+ raise AgentRegistryError("agents registry must contain a non-empty agents list")
+
+ agents: list[AgentDefinition] = []
+ seen: set[str] = set()
+ for entry in entries:
+ if not isinstance(entry, Mapping):
+ raise AgentRegistryError("each agent entry requires id and label")
+ agent_id = _required_text(entry, "id")
+ label = _required_text(entry, "label")
+ base_url = _optional_text(entry, "base_url")
+ workspace_path = _optional_text(entry, "workspace_path")
+ if agent_id in seen:
+ raise AgentRegistryError(f"duplicate agent id: {agent_id}")
+ seen.add(agent_id)
+ agents.append(
+ AgentDefinition(
+ agent_id=agent_id,
+ label=label,
+ base_url=base_url,
+ workspace_path=workspace_path,
+ )
+ )
+
+ user_agents = raw.get("user_agents")
+ if user_agents is not None:
+ if not isinstance(user_agents, Mapping):
+ raise AgentRegistryError("user_agents must be a mapping of user_id to agent_id")
+ user_agents = {str(k): str(v) for k, v in user_agents.items()}
+
+ return AgentRegistry(agents, user_agents)
diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py
index 08638cb..411f037 100644
--- a/adapter/matrix/bot.py
+++ b/adapter/matrix/bot.py
@@ -1,32 +1,71 @@
from __future__ import annotations
import asyncio
+import logging
import os
+import re
from dataclasses import dataclass
from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
import structlog
+from dotenv import load_dotenv
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
MatrixRoom,
RoomMemberEvent,
+ RoomMessage,
+ RoomMessageAudio,
+ RoomMessageFile,
+ RoomMessageImage,
RoomMessageText,
+ RoomMessageVideo,
)
from nio.responses import SyncResponse
-from dotenv import load_dotenv
+from adapter.matrix.agent_registry import AgentRegistry, AgentRegistryError, load_agent_registry
from adapter.matrix.converter import from_room_event
+from adapter.matrix.files import (
+ download_matrix_attachment,
+ matrix_msgtype_for_attachment,
+ resolve_workspace_attachment_path,
+)
from adapter.matrix.handlers import register_matrix_handlers
-from adapter.matrix.handlers.auth import handle_invite
+from adapter.matrix.handlers.auth import (
+ default_agent_notice,
+ handle_invite,
+ provision_workspace_chat,
+ restore_workspace_access,
+)
+from adapter.matrix.handlers.context_commands import (
+ LOAD_PROMPT,
+)
+from adapter.matrix.reconciliation import reconcile_startup_state
from adapter.matrix.room_router import resolve_chat_id
-from adapter.matrix.store import get_room_meta, set_pending_confirm
+from adapter.matrix.routed_platform import RoutedPlatformClient
+from adapter.matrix.store import (
+ add_staged_attachment,
+ clear_load_pending,
+ clear_staged_attachments,
+ get_load_pending,
+ get_room_meta,
+ get_staged_attachments,
+ next_platform_chat_id,
+ remove_staged_attachment_at,
+ set_pending_confirm,
+ set_platform_chat_id,
+ set_room_meta,
+)
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
+ Attachment,
+ IncomingCommand,
+ IncomingMessage,
OutgoingEvent,
OutgoingMessage,
OutgoingNotification,
@@ -35,7 +74,10 @@ from core.protocol import (
)
from core.settings import SettingsManager
from core.store import InMemoryStore, SQLiteStore, StateStore
+from sdk.interface import PlatformClient, PlatformError
from sdk.mock import MockPlatformClient
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
logger = structlog.get_logger(__name__)
@@ -44,41 +86,161 @@ load_dotenv(Path(__file__).resolve().parents[2] / ".env")
@dataclass
class MatrixRuntime:
- platform: MockPlatformClient
+ platform: PlatformClient
store: StateStore
chat_mgr: ChatManager
auth_mgr: AuthManager
settings_mgr: SettingsManager
dispatcher: EventDispatcher
+ agent_routing_enabled: bool = False
+ registry: AgentRegistry | None = None
-def build_event_dispatcher(platform: MockPlatformClient, store: StateStore) -> EventDispatcher:
+def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher:
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
+ prototype_state = getattr(platform, "_prototype_state", None)
+ agent_base_url = _agent_base_url_from_env()
+ registry = _load_agent_registry_from_env()
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
- register_matrix_handlers(dispatcher, store=store)
+ register_matrix_handlers(
+ dispatcher,
+ store=store,
+ registry=registry,
+ prototype_state=prototype_state,
+ agent_base_url=agent_base_url,
+ )
return dispatcher
+def _normalize_agent_base_url(url: str) -> str:
+ parsed = urlsplit(url)
+ path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
+
+
+def _ws_debug_enabled() -> bool:
+ value = os.environ.get("SURFACES_DEBUG_WS", "")
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _configure_debug_logging() -> None:
+ if not _ws_debug_enabled():
+ return
+ root_logger = logging.getLogger()
+ if not root_logger.handlers:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)-8s] %(name)s %(message)s",
+ )
+ elif root_logger.level > logging.INFO:
+ root_logger.setLevel(logging.INFO)
+ logging.getLogger("lambda_agent_api").setLevel(logging.INFO)
+ logging.getLogger("lambda_agent_api.agent_api").setLevel(logging.INFO)
+
+
+def _agent_base_url_from_env() -> str:
+ if base_url := os.environ.get("AGENT_BASE_URL"):
+ return base_url
+ if ws_url := os.environ.get("AGENT_WS_URL"):
+ return _normalize_agent_base_url(ws_url)
+ return "http://127.0.0.1:8000"
+
+
+def _load_agent_registry_from_env(required: bool = False) -> AgentRegistry | None:
+ registry_path = os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip()
+ if not registry_path:
+ if required:
+ raise RuntimeError(
+ "MATRIX_AGENT_REGISTRY_PATH is required when MATRIX_PLATFORM_BACKEND=real"
+ )
+ return None
+ try:
+ registry = load_agent_registry(registry_path)
+ except (AgentRegistryError, OSError) as exc:
+ raise RuntimeError(f"failed to load matrix agent registry: {registry_path}") from exc
+ if _ws_debug_enabled():
+ logger.warning(
+ "matrix_agent_registry_loaded",
+ registry_path=registry_path,
+ agent_count=len(registry.agents),
+ )
+ for agent in registry.agents:
+ logger.warning(
+ "matrix_agent_registry_entry",
+ registry_path=registry_path,
+ agent_id=agent.agent_id,
+ label=agent.label,
+ configured_base_url=agent.base_url,
+ normalized_base_url=_normalize_agent_base_url(agent.base_url)
+ if agent.base_url
+ else "",
+ workspace_path=agent.workspace_path,
+ )
+ return registry
+
+
+def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if _ws_debug_enabled():
+ logger.warning(
+ "matrix_platform_backend_selected",
+ backend=backend,
+ global_agent_base_url=_agent_base_url_from_env(),
+ registry_path=os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip(),
+ )
+ if backend == "real":
+ prototype_state = PrototypeStateStore()
+ registry = _load_agent_registry_from_env(required=True)
+ assert registry is not None
+ global_base_url = _agent_base_url_from_env()
+ delegates = {
+ agent.agent_id: RealPlatformClient(
+ agent_id=agent.agent_id,
+ agent_base_url=agent.base_url or global_base_url,
+ prototype_state=prototype_state,
+ platform="matrix",
+ )
+ for agent in registry.agents
+ }
+ return RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates=delegates,
+ )
+ return MockPlatformClient()
+
+
def build_runtime(
- platform: MockPlatformClient | None = None,
+ platform: PlatformClient | None = None,
store: StateStore | None = None,
client: AsyncClient | None = None,
) -> MatrixRuntime:
- platform = platform or MockPlatformClient()
store = store or InMemoryStore()
chat_mgr = ChatManager(platform, store)
+ platform = platform or _build_platform_from_env(store=store, chat_mgr=chat_mgr)
+ chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
+ prototype_state = getattr(platform, "_prototype_state", None)
+ agent_base_url = _agent_base_url_from_env()
+ registry = _load_agent_registry_from_env()
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
- register_matrix_handlers(dispatcher, client=client, store=store)
+ register_matrix_handlers(
+ dispatcher,
+ client=client,
+ store=store,
+ registry=registry,
+ prototype_state=prototype_state,
+ agent_base_url=agent_base_url,
+ )
return MatrixRuntime(
platform=platform,
store=store,
@@ -86,6 +248,8 @@ def build_runtime(
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
dispatcher=dispatcher,
+ agent_routing_enabled=isinstance(platform, RoutedPlatformClient),
+ registry=registry,
)
@@ -94,15 +258,524 @@ class MatrixBot:
self.client = client
self.runtime = runtime
+ async def _ensure_platform_chat_id(self, room_id: str, room_meta: dict | None) -> None:
+ if not room_meta:
+ return
+ if room_meta.get("redirect_room_id"):
+ return
+ if room_meta.get("platform_chat_id"):
+ return
+ await set_platform_chat_id(
+ self.runtime.store,
+ room_id,
+ await next_platform_chat_id(self.runtime.store),
+ )
+
+ async def _refresh_room_agent_assignment(
+ self, room_id: str, matrix_user_id: str, room_meta: dict | None
+ ) -> tuple[dict | None, bool]:
+ if not room_meta or room_meta.get("redirect_room_id") or self.runtime.registry is None:
+ return room_meta, False
+
+ assignment = self.runtime.registry.resolve_agent_for_user(matrix_user_id)
+ updated = dict(room_meta)
+ should_warn_default = False
+
+ if assignment.source == "configured" and (
+ updated.get("agent_id") != assignment.agent_id
+ or updated.get("agent_assignment") != "configured"
+ ):
+ updated["agent_id"] = assignment.agent_id
+ updated["agent_assignment"] = "configured"
+ updated.pop("default_agent_notice_sent", None)
+ elif assignment.source == "default":
+ if not updated.get("agent_id"):
+ updated["agent_id"] = assignment.agent_id
+ if updated.get("agent_id") == assignment.agent_id:
+ updated["agent_assignment"] = "default"
+ should_warn_default = not updated.get("default_agent_notice_sent")
+ updated["default_agent_notice_sent"] = True
+
+ if updated != room_meta:
+ await set_room_meta(self.runtime.store, room_id, updated)
+ return updated, should_warn_default
+ return room_meta, should_warn_default
+
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
- chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
- incoming = from_room_event(event, room_id=room.room_id, chat_id=chat_id)
+ sender = getattr(event, "sender", None)
+ body = (getattr(event, "body", None) or "").strip()
+ room_meta = await get_room_meta(self.runtime.store, room.room_id)
+ if room_meta is not None and not room_meta.get("redirect_room_id"):
+ await self._ensure_platform_chat_id(room.room_id, room_meta)
+ room_meta, warn_default_agent = await self._refresh_room_agent_assignment(
+ room.room_id, sender, room_meta
+ )
+ if warn_default_agent and not body.startswith("!"):
+ await self._send_all(
+ room.room_id,
+ [OutgoingMessage(chat_id=room.room_id, text=default_agent_notice())],
+ )
+
+ load_pending = await get_load_pending(self.runtime.store, sender, room.room_id)
+ if load_pending is not None and (body.isdigit() or body == "!cancel"):
+ outgoing = await self._handle_load_selection(sender, room.room_id, body, load_pending)
+ await self._send_all(room.room_id, outgoing)
+ return
+
+ if room_meta is None:
+ outgoing = await self._bootstrap_unregistered_room(room, sender)
+ if outgoing:
+ await self._send_all(room.room_id, outgoing)
+ return
+ elif room_meta.get("redirect_room_id"):
+ display_name = getattr(room, "display_name", None) or sender
+ if body == "!new":
+ try:
+ created = await provision_workspace_chat(
+ self.client,
+ sender,
+ display_name,
+ self.runtime.platform,
+ self.runtime.store,
+ self.runtime.auth_mgr,
+ self.runtime.chat_mgr,
+ registry=self.runtime.registry,
+ )
+ except Exception as exc:
+ logger.warning(
+ "matrix_entry_room_new_chat_failed",
+ room_id=room.room_id,
+ sender=sender,
+ error=str(exc),
+ )
+ await self._send_all(
+ room.room_id,
+ [
+ OutgoingMessage(
+ chat_id=room.room_id,
+ text="Не удалось создать новый рабочий чат.",
+ )
+ ],
+ )
+ return
+
+ welcome = f"Создал новый рабочий чат {created['room_name']}."
+ if created.get("agent_assignment") == "default":
+ welcome = f"{welcome}\n\n{default_agent_notice()}"
+ await self.client.room_send(
+ created["chat_room_id"],
+ "m.room.message",
+ {"msgtype": "m.text", "body": welcome},
+ )
+ await set_room_meta(
+ self.runtime.store,
+ room.room_id,
+ {
+ **room_meta,
+ "redirect_room_id": created["chat_room_id"],
+ "redirect_chat_id": created["chat_id"],
+ },
+ )
+ await self._send_all(
+ room.room_id,
+ [
+ OutgoingMessage(
+ chat_id=room.room_id,
+ text=(
+ f"Создал рабочий чат {created['room_name']} "
+ f"({created['chat_id']}) и отправил приглашение."
+ ),
+ )
+ ],
+ )
+ return
+
+ restored = await restore_workspace_access(
+ self.client,
+ sender,
+ display_name,
+ self.runtime.platform,
+ self.runtime.store,
+ self.runtime.auth_mgr,
+ self.runtime.chat_mgr,
+ registry=self.runtime.registry,
+ )
+ redirect_room_id = room_meta["redirect_room_id"]
+ redirect_chat_id = room_meta.get("redirect_chat_id", "рабочий чат")
+ if restored.get("created_new_chat"):
+ text = (
+ f"Создал новый рабочий чат {restored['room_name']} "
+ f"({restored['chat_id']}) и отправил приглашение."
+ )
+ else:
+ text = (
+ f"Рабочий чат уже создан: {redirect_chat_id}. "
+ "Я повторно отправил приглашения в пространство Lambda и рабочие чаты. "
+ "Чтобы создать новый чат, напишите !new здесь."
+ )
+ await self._send_all(
+ room.room_id,
+ [
+ OutgoingMessage(
+ chat_id=room.room_id,
+ text=text,
+ )
+ ],
+ )
+ logger.info(
+ "matrix_redirect_entry_room",
+ room_id=room.room_id,
+ redirect_room_id=redirect_room_id,
+ user=sender,
+ )
+ return
+ if not body.startswith("!") and self.runtime.agent_routing_enabled:
+ pass
+
+ local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
+ incoming = from_room_event(event, room_id=room.room_id, chat_id=local_chat_id)
if incoming is None:
return
- outgoing = await self.runtime.dispatcher.dispatch(incoming)
- await self._send_all(room.room_id, outgoing)
+ if isinstance(incoming, IncomingCommand) and incoming.command in {
+ "matrix_list_attachments",
+ "matrix_remove_attachment",
+ }:
+ outgoing = await self._handle_staged_attachment_command(
+ room.room_id,
+ sender,
+ incoming,
+ )
+ await self._send_all(room.room_id, outgoing)
+ return
+ if self._is_file_only_event(event, incoming):
+ materialized = await self._materialize_incoming_attachments(
+ room.room_id,
+ sender,
+ incoming,
+ )
+ await self._stage_attachments(room.room_id, sender, materialized.attachments)
+ return
+ if isinstance(incoming, IncomingMessage) and incoming.attachments:
+ incoming = await self._materialize_incoming_attachments(
+ room.room_id,
+ sender,
+ incoming,
+ )
+ clear_staged_after_dispatch = False
+ if isinstance(incoming, IncomingMessage) and incoming.text:
+ incoming, clear_staged_after_dispatch = await self._merge_staged_attachments(
+ room.room_id,
+ sender,
+ incoming,
+ )
+ agent_id = (room_meta or {}).get("agent_id")
+ if _ws_debug_enabled() and not body.startswith("!"):
+ logger.warning(
+ "matrix_incoming_message_route",
+ room_id=room.room_id,
+ sender=sender,
+ local_chat_id=local_chat_id,
+ agent_id=agent_id,
+ platform_chat_id=(room_meta or {}).get("platform_chat_id"),
+ )
+ workspace_root = self._agent_workspace_root(agent_id)
+ try:
+ outgoing = await self.runtime.dispatcher.dispatch(incoming)
+ except PlatformError as exc:
+ logger.warning(
+ "matrix_message_platform_error",
+ room_id=room.room_id,
+ sender=getattr(event, "sender", None),
+ code=exc.code,
+ error=str(exc),
+ )
+ outgoing = [
+ OutgoingMessage(
+ chat_id=local_chat_id,
+ text="Сервис временно недоступен. Попробуйте ещё раз позже.",
+ )
+ ]
+ else:
+ if clear_staged_after_dispatch:
+ await clear_staged_attachments(self.runtime.store, room.room_id, sender)
+ await self._send_all(room.room_id, outgoing, workspace_root=workspace_root)
+
+ def _is_file_only_event(
+ self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand
+ ) -> bool:
+ return (
+ isinstance(incoming, IncomingMessage)
+ and bool(incoming.attachments)
+ and not isinstance(event, RoomMessageText)
+ )
+
+ async def _stage_attachments(
+ self,
+ room_id: str,
+ user_id: str,
+ attachments: list,
+ ) -> None:
+ for attachment in attachments:
+ await add_staged_attachment(
+ self.runtime.store,
+ room_id,
+ user_id,
+ {
+ "type": attachment.type,
+ "url": attachment.url,
+ "filename": attachment.filename,
+ "mime_type": attachment.mime_type,
+ "workspace_path": attachment.workspace_path,
+ },
+ )
+
+ async def _format_staged_attachments(
+ self,
+ room_id: str,
+ user_id: str,
+ *,
+ include_hint: bool = False,
+ ) -> str:
+ attachments = await get_staged_attachments(self.runtime.store, room_id, user_id)
+ if not attachments:
+ return "Нет сохраненных вложений."
+
+ lines = ["Вложения в очереди:"]
+ for index, attachment in enumerate(attachments, start=1):
+ lines.append(f"{index}. {attachment.get('filename') or 'attachment'}")
+ if include_hint:
+ lines.extend(
+ [
+ "",
+ "Следующее сообщение отправит файлы агенту.",
+ "Команды: !list, !remove , !remove all",
+ ]
+ )
+ return "\n".join(lines)
+
+ async def _handle_staged_attachment_command(
+ self,
+ room_id: str,
+ user_id: str,
+ incoming: IncomingCommand,
+ ) -> list[OutgoingEvent]:
+ if incoming.command == "matrix_list_attachments":
+ return [
+ OutgoingMessage(
+ chat_id=incoming.chat_id,
+ text=await self._format_staged_attachments(room_id, user_id),
+ )
+ ]
+
+ arg = incoming.args[0] if incoming.args else ""
+ if arg == "all":
+ await clear_staged_attachments(self.runtime.store, room_id, user_id)
+ return [OutgoingMessage(chat_id=incoming.chat_id, text="Все вложения удалены.")]
+
+ try:
+ index = int(arg) - 1
+ except ValueError:
+ return [OutgoingMessage(chat_id=incoming.chat_id, text="Нет такого вложения.")]
+
+ removed = await remove_staged_attachment_at(self.runtime.store, room_id, user_id, index)
+ if removed is None:
+ return [OutgoingMessage(chat_id=incoming.chat_id, text="Нет такого вложения.")]
+ return [
+ OutgoingMessage(
+ chat_id=incoming.chat_id,
+ text=await self._format_staged_attachments(room_id, user_id),
+ )
+ ]
+
+ async def _merge_staged_attachments(
+ self,
+ room_id: str,
+ user_id: str,
+ incoming: IncomingMessage,
+ ) -> tuple[IncomingMessage, bool]:
+ staged = await get_staged_attachments(self.runtime.store, room_id, user_id)
+ if not staged:
+ return incoming, False
+ attachments = [
+ Attachment(
+ type=item.get("type", "document"),
+ url=item.get("url"),
+ filename=item.get("filename"),
+ mime_type=item.get("mime_type"),
+ workspace_path=item.get("workspace_path"),
+ )
+ for item in staged
+ ]
+ return (
+ IncomingMessage(
+ user_id=incoming.user_id,
+ platform=incoming.platform,
+ chat_id=incoming.chat_id,
+ text=incoming.text,
+ attachments=attachments,
+ reply_to=incoming.reply_to,
+ ),
+ True,
+ )
+
+ def _agent_workspace_root(self, agent_id: str | None) -> Path:
+ default = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
+ if agent_id is None or self.runtime.registry is None:
+ return default
+ try:
+ agent = self.runtime.registry.get(agent_id)
+ if agent.workspace_path:
+ return Path(agent.workspace_path)
+ except Exception:
+ pass
+ return default
+
+ async def _materialize_incoming_attachments(
+ self,
+ room_id: str,
+ matrix_user_id: str,
+ incoming: IncomingMessage,
+ ) -> IncomingMessage:
+ room_meta = await get_room_meta(self.runtime.store, room_id)
+ agent_id = (room_meta or {}).get("agent_id")
+ workspace_root = self._agent_workspace_root(agent_id)
+ materialized = []
+ for attachment in incoming.attachments:
+ materialized.append(
+ await download_matrix_attachment(
+ client=self.client,
+ workspace_root=workspace_root,
+ matrix_user_id=matrix_user_id,
+ room_id=room_id,
+ attachment=attachment,
+ )
+ )
+ return IncomingMessage(
+ user_id=incoming.user_id,
+ platform=incoming.platform,
+ chat_id=incoming.chat_id,
+ text=incoming.text,
+ attachments=materialized,
+ reply_to=incoming.reply_to,
+ )
+
+ async def _bootstrap_unregistered_room(
+ self,
+ room: MatrixRoom,
+ sender: str,
+ ) -> list[OutgoingEvent] | None:
+ if not hasattr(self.client, "room_create") or not hasattr(self.client, "room_put_state"):
+ return None
+ display_name = getattr(room, "display_name", None) or sender
+ try:
+ created = await provision_workspace_chat(
+ self.client,
+ sender,
+ display_name,
+ self.runtime.platform,
+ self.runtime.store,
+ self.runtime.auth_mgr,
+ self.runtime.chat_mgr,
+ registry=self.runtime.registry,
+ )
+ except Exception as exc:
+ logger.warning(
+ "matrix_unregistered_room_bootstrap_failed",
+ room_id=room.room_id,
+ sender=sender,
+ error=str(exc),
+ )
+ return [
+ OutgoingMessage(
+ chat_id=room.room_id,
+ text="Не удалось подготовить рабочий чат. Попробуйте ещё раз позже.",
+ )
+ ]
+
+ welcome = (
+ f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n"
+ "Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help"
+ )
+ if created.get("agent_assignment") == "default":
+ welcome = f"{welcome}\n\n{default_agent_notice()}"
+ await set_room_meta(
+ self.runtime.store,
+ room.room_id,
+ {
+ "matrix_user_id": sender,
+ "redirect_room_id": created["chat_room_id"],
+ "redirect_chat_id": created["chat_id"],
+ },
+ )
+ await self.client.room_send(
+ created["chat_room_id"],
+ "m.room.message",
+ {"msgtype": "m.text", "body": welcome},
+ )
+ return [
+ OutgoingMessage(
+ chat_id=room.room_id,
+ text=(
+ f"Создал рабочий чат {created['room_name']} ({created['chat_id']}) "
+ "и добавил его в пространство Lambda. "
+ "Открой приглашённую комнату для продолжения."
+ ),
+ )
+ ]
+
+ async def _handle_load_selection(
+ self,
+ user_id: str,
+ room_id: str,
+ text: str,
+ pending: dict,
+ ) -> list[OutgoingEvent]:
+ saves = pending.get("saves", [])
+ if text in {"0", "!cancel"}:
+ await clear_load_pending(self.runtime.store, user_id, room_id)
+ return [OutgoingMessage(chat_id=room_id, text="Отменено.")]
+
+ index = int(text) - 1
+ if index < 0 or index >= len(saves):
+ return [
+ OutgoingMessage(
+ chat_id=room_id,
+ text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.",
+ )
+ ]
+
+ name = saves[index]["name"]
+ await clear_load_pending(self.runtime.store, user_id, room_id)
+ prototype_state = getattr(self.runtime.platform, "_prototype_state", None)
+ if prototype_state is not None:
+ room_meta = await get_room_meta(self.runtime.store, room_id)
+ context_keys = []
+ if room_meta is not None:
+ platform_chat_id = room_meta.get("platform_chat_id")
+ if platform_chat_id:
+ context_keys.append(platform_chat_id)
+ chat_id = room_meta.get("chat_id")
+ if chat_id:
+ context_keys.append(chat_id)
+ if not context_keys:
+ context_keys.append(room_id)
+ for context_key in dict.fromkeys(context_keys):
+ await prototype_state.set_current_session(context_key, name)
+
+ try:
+ await self.runtime.platform.send_message(
+ user_id,
+ room_id,
+ LOAD_PROMPT.format(name=name),
+ )
+ except Exception as exc:
+ logger.warning("load_agent_call_failed", error=str(exc))
+ return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")]
+ return [
+ OutgoingMessage(chat_id=room_id, text=f"Запрос на загрузку отправлен агенту: {name}")
+ ]
async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
if getattr(event, "sender", None) == self.client.user_id:
@@ -117,11 +790,23 @@ class MatrixBot:
self.runtime.store,
self.runtime.auth_mgr,
self.runtime.chat_mgr,
+ self.runtime.registry,
)
- async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
+ async def _send_all(
+ self,
+ room_id: str,
+ outgoing: list[OutgoingEvent],
+ workspace_root: Path | None = None,
+ ) -> None:
for event in outgoing:
- await send_outgoing(self.client, room_id, event, store=self.runtime.store)
+ await send_outgoing(
+ self.client,
+ room_id,
+ event,
+ store=self.runtime.store,
+ workspace_root=workspace_root,
+ )
async def prepare_live_sync(client: AsyncClient) -> str | None:
@@ -130,11 +815,13 @@ async def prepare_live_sync(client: AsyncClient) -> str | None:
return response.next_batch
return None
+
async def send_outgoing(
client: AsyncClient,
room_id: str,
event: OutgoingEvent,
store: StateStore | None = None,
+ workspace_root: Path | None = None,
) -> None:
if isinstance(event, OutgoingTyping):
await client.room_typing(room_id, event.is_typing, timeout=25000)
@@ -144,7 +831,39 @@ async def send_outgoing(
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
return
if isinstance(event, OutgoingMessage):
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
+ if event.text:
+ await client.room_send(
+ room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}
+ )
+ if event.attachments:
+ workspace_root = workspace_root or Path(
+ os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")
+ )
+ for attachment in event.attachments:
+ if not attachment.workspace_path:
+ continue
+ file_path = resolve_workspace_attachment_path(
+ workspace_root, attachment.workspace_path
+ )
+ with file_path.open("rb") as handle:
+ upload_response, _ = await client.upload(
+ handle,
+ content_type=attachment.mime_type or "application/octet-stream",
+ filename=attachment.filename or file_path.name,
+ filesize=file_path.stat().st_size,
+ )
+ content_uri = getattr(upload_response, "content_uri", None)
+ if not content_uri:
+ raise RuntimeError(f"Matrix upload failed for {file_path}")
+ await client.room_send(
+ room_id,
+ "m.room.message",
+ {
+ "msgtype": matrix_msgtype_for_attachment(attachment),
+ "body": attachment.filename or file_path.name,
+ "url": content_uri,
+ },
+ )
return
if isinstance(event, OutgoingUI):
lines = [event.text]
@@ -176,6 +895,7 @@ async def send_outgoing(
async def main() -> None:
+ _configure_debug_logging()
homeserver = os.environ.get("MATRIX_HOMESERVER")
user_id = os.environ.get("MATRIX_USER_ID")
device_id = os.environ.get("MATRIX_DEVICE_ID", "")
@@ -207,9 +927,19 @@ async def main() -> None:
await client.login(password=password, device_name="surfaces-bot")
since_token = await prepare_live_sync(client)
+ await reconcile_startup_state(client, runtime)
bot = MatrixBot(client, runtime)
- client.add_event_callback(bot.on_room_message, RoomMessageText)
+ client.add_event_callback(
+ bot.on_room_message,
+ (
+ RoomMessageText,
+ RoomMessageFile,
+ RoomMessageImage,
+ RoomMessageVideo,
+ RoomMessageAudio,
+ ),
+ )
client.add_event_callback(bot.on_member, (InviteMemberEvent, RoomMemberEvent))
logger.info(
@@ -219,9 +949,21 @@ async def main() -> None:
store_path=store_path,
request_timeout=client_config.request_timeout,
)
+ if _ws_debug_enabled():
+ logger.warning(
+ "matrix_ws_debug_enabled",
+ homeserver=homeserver,
+ user_id=user_id,
+ backend=os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower(),
+ global_agent_base_url=_agent_base_url_from_env(),
+ registry_path=os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip(),
+ )
try:
await client.sync_forever(timeout=30000, since=since_token)
finally:
+ close = getattr(runtime.platform, "close", None)
+ if callable(close):
+ await close()
await client.close()
diff --git a/adapter/matrix/converter.py b/adapter/matrix/converter.py
index 00fcdc4..a19d8ea 100644
--- a/adapter/matrix/converter.py
+++ b/adapter/matrix/converter.py
@@ -14,42 +14,53 @@ PLATFORM = "matrix"
def extract_attachments(event: Any) -> list[Attachment]:
+ source = getattr(event, "source", {}) or {}
+ content = source.get("content", {}) or getattr(event, "content", {}) or {}
msgtype = getattr(event, "msgtype", None)
if msgtype is None:
- content = getattr(event, "content", {}) or {}
msgtype = content.get("msgtype")
+ url = content.get("url") or getattr(event, "url", None)
+ filename = content.get("body") or getattr(event, "body", None)
+ mime_type = content.get("mimetype") or getattr(event, "mimetype", None)
+ if mime_type is None:
+ info = content.get("info") or {}
+ if isinstance(info, dict):
+ mime_type = info.get("mimetype")
if msgtype == "m.image":
return [
Attachment(
type="image",
- url=getattr(event, "url", None),
- mime_type=getattr(event, "mimetype", None),
+ url=url,
+ filename=filename,
+ mime_type=mime_type,
)
]
if msgtype == "m.file":
return [
Attachment(
type="document",
- url=getattr(event, "url", None),
- filename=getattr(event, "body", None),
- mime_type=getattr(event, "mimetype", None),
+ url=url,
+ filename=filename,
+ mime_type=mime_type,
)
]
if msgtype == "m.audio":
return [
Attachment(
type="audio",
- url=getattr(event, "url", None),
- mime_type=getattr(event, "mimetype", None),
+ url=url,
+ filename=filename,
+ mime_type=mime_type,
)
]
if msgtype == "m.video":
return [
Attachment(
type="video",
- url=getattr(event, "url", None),
- mime_type=getattr(event, "mimetype", None),
+ url=url,
+ filename=filename,
+ mime_type=mime_type,
)
]
return []
@@ -75,6 +86,24 @@ def from_command(body: str, sender: str, chat_id: str, room_id: str | None = Non
},
)
+ if command == "list" and not args:
+ return IncomingCommand(
+ user_id=sender,
+ platform=PLATFORM,
+ chat_id=chat_id,
+ command="matrix_list_attachments",
+ args=[],
+ )
+
+ if command == "remove" and len(args) == 1:
+ return IncomingCommand(
+ user_id=sender,
+ platform=PLATFORM,
+ chat_id=chat_id,
+ command="matrix_remove_attachment",
+ args=[args[0]],
+ )
+
aliases = {
"skills": "settings_skills",
"connectors": "settings_connectors",
diff --git a/adapter/matrix/files.py b/adapter/matrix/files.py
new file mode 100644
index 0000000..0845684
--- /dev/null
+++ b/adapter/matrix/files.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import mimetypes
+import re
+from pathlib import Path, PurePosixPath
+
+from core.protocol import Attachment
+
+
+def _sanitize_filename(value: str) -> str:
+ filename = PurePosixPath(str(value).replace("\\", "/")).name.strip()
+ cleaned = re.sub(r"[\x00-\x1f\x7f<>:\"/\\|?*]+", "_", filename)
+ cleaned = cleaned.strip(" .")
+ return cleaned or "attachment.bin"
+
+
+def _default_filename(attachment: Attachment) -> str:
+ if attachment.filename:
+ return attachment.filename
+
+ extension = mimetypes.guess_extension(attachment.mime_type or "") or ""
+ base = {
+ "image": "image",
+ "audio": "audio",
+ "video": "video",
+ "document": "attachment",
+ }.get(attachment.type, "attachment")
+ return f"{base}{extension}"
+
+
+def _with_copy_index(filename: str, index: int) -> str:
+ path = Path(filename)
+ suffix = path.suffix
+ stem = path.stem if suffix else filename
+ return f"{stem} ({index}){suffix}"
+
+
+def _unique_workspace_relative_path(workspace_root: Path, filename: str) -> tuple[str, Path]:
+ safe_name = _sanitize_filename(filename)
+ candidate = workspace_root / safe_name
+ if not candidate.exists():
+ return safe_name, candidate
+
+ index = 1
+ while True:
+ indexed_name = _with_copy_index(safe_name, index)
+ candidate = workspace_root / indexed_name
+ if not candidate.exists():
+ return indexed_name, candidate
+ index += 1
+
+
+def build_agent_workspace_path(
+ *,
+ workspace_root: Path,
+ filename: str,
+) -> tuple[str, Path]:
+ """Saves user files directly to {workspace_root}/{filename}.
+
+ The returned relative path is what gets passed to agent.send_message(attachments=[...]).
+ """
+ return _unique_workspace_relative_path(workspace_root, filename)
+
+
+async def download_matrix_attachment(
+ *,
+ client,
+ workspace_root: Path,
+ matrix_user_id: str,
+ room_id: str,
+ attachment: Attachment,
+ timestamp: str | None = None,
+) -> Attachment:
+ if not attachment.url:
+ return attachment
+
+ filename = _default_filename(attachment)
+
+ del matrix_user_id, room_id, timestamp
+ relative_path, absolute_path = build_agent_workspace_path(
+ workspace_root=workspace_root,
+ filename=filename,
+ )
+
+ absolute_path.parent.mkdir(parents=True, exist_ok=True)
+
+ response = await client.download(attachment.url)
+ body = getattr(response, "body", None)
+ if body is None:
+ raise RuntimeError(f"Matrix download response for {attachment.url} has no body")
+ absolute_path.write_bytes(body)
+
+ return Attachment(
+ type=attachment.type,
+ url=attachment.url,
+ filename=filename,
+ mime_type=attachment.mime_type,
+ workspace_path=relative_path,
+ )
+
+
+def resolve_workspace_attachment_path(workspace_root: Path, workspace_path: str) -> Path:
+ path = Path(workspace_path)
+ if path.is_absolute():
+ return path
+ return workspace_root / path
+
+
+def matrix_msgtype_for_attachment(attachment: Attachment) -> str:
+ return {
+ "image": "m.image",
+ "audio": "m.audio",
+ "video": "m.video",
+ }.get(attachment.type, "m.file")
diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py
index 9dbe8c2..30adf59 100644
--- a/adapter/matrix/handlers/__init__.py
+++ b/adapter/matrix/handlers/__init__.py
@@ -7,6 +7,12 @@ from adapter.matrix.handlers.chat import (
make_handle_rename,
)
from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
+from adapter.matrix.handlers.context_commands import (
+ make_handle_context,
+ make_handle_load,
+ make_handle_reset,
+ make_handle_save,
+)
from adapter.matrix.handlers.settings import (
handle_help,
handle_settings,
@@ -18,18 +24,32 @@ from adapter.matrix.handlers.settings import (
handle_settings_status,
handle_settings_whoami,
handle_toggle_skill,
+ handle_unknown_command,
)
from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand
-def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None:
- dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
+def register_matrix_handlers(
+ dispatcher: EventDispatcher,
+ client=None,
+ store=None,
+ registry=None,
+ prototype_state=None,
+ agent_base_url: str = "http://127.0.0.1:8000",
+) -> None:
+ dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store, registry))
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store))
dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store))
dispatcher.register(IncomingCommand, "help", handle_help)
dispatcher.register(IncomingCommand, "settings", handle_settings)
+ if prototype_state is not None:
+ clear_handler = make_handle_reset(store, prototype_state)
+ dispatcher.register(IncomingCommand, "clear", clear_handler)
+ dispatcher.register(IncomingCommand, "reset", clear_handler)
+ else:
+ dispatcher.register(IncomingCommand, "reset", handle_settings)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)
dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul)
@@ -41,3 +61,13 @@ def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=Non
dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store))
dispatcher.register(IncomingCallback, "cancel", make_handle_cancel(store))
dispatcher.register(IncomingCallback, "toggle_skill", handle_toggle_skill)
+ dispatcher.register(IncomingCommand, "*", handle_unknown_command)
+
+ if prototype_state is not None:
+ dispatcher.register(
+ IncomingCommand,
+ "save",
+ make_handle_save(None, store, prototype_state),
+ )
+ dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state))
+ dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state))
diff --git a/adapter/matrix/handlers/auth.py b/adapter/matrix/handlers/auth.py
index 83f1ac6..064448d 100644
--- a/adapter/matrix/handlers/auth.py
+++ b/adapter/matrix/handlers/auth.py
@@ -1,14 +1,15 @@
from __future__ import annotations
-import structlog
from typing import Any
+import structlog
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
+from adapter.matrix.agent_registry import AgentRegistry
from adapter.matrix.store import (
get_user_meta,
- next_chat_id,
+ next_platform_chat_id,
set_room_meta,
set_user_meta,
)
@@ -16,16 +17,47 @@ from adapter.matrix.store import (
logger = structlog.get_logger(__name__)
-async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr, chat_mgr) -> None:
- matrix_user_id = getattr(event, "sender", "")
- display_name = getattr(room, "display_name", None) or matrix_user_id
+def _default_room_name(chat_id: str) -> str:
+ suffix = chat_id[1:] if chat_id.startswith("C") else chat_id
+ return f"Чат {suffix}"
- existing = await get_user_meta(store, matrix_user_id)
- if existing and existing.get("space_id"):
- return
- await client.join(room.room_id)
+def default_agent_notice() -> str:
+ return (
+ "Внимание: ваш Matrix ID не найден в конфиге агентов. "
+ "Пока используется агент по умолчанию. После добавления вас в конфиг "
+ "бот переключит существующие комнаты на назначенного агента."
+ )
+
+async def _invite_if_possible(client: Any, room_id: str, matrix_user_id: str) -> bool:
+ room_invite = getattr(client, "room_invite", None)
+ if not callable(room_invite):
+ return False
+ try:
+ await room_invite(room_id, matrix_user_id)
+ return True
+ except Exception as exc:
+ logger.warning(
+ "matrix_workspace_reinvite_failed",
+ room_id=room_id,
+ user=matrix_user_id,
+ error=str(exc),
+ )
+ return False
+
+
+async def provision_workspace_chat(
+ client: Any,
+ matrix_user_id: str,
+ display_name: str,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ room_name_override: str | None = None,
+ registry: AgentRegistry | None = None,
+) -> dict:
user = await platform.get_or_create_user(
external_id=matrix_user_id,
platform="matrix",
@@ -34,24 +66,41 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
await auth_mgr.confirm(matrix_user_id)
homeserver = matrix_user_id.split(":")[-1]
+ user_meta = await get_user_meta(store, matrix_user_id) or {}
+ space_id = user_meta.get("space_id")
- space_resp = await client.room_create(
- name=f"Lambda — {display_name}",
- space=True,
- visibility=RoomVisibility.private,
- invite=[matrix_user_id],
- )
- if isinstance(space_resp, RoomCreateError):
- logger.error(
- "space creation failed",
- user=matrix_user_id,
- error=getattr(space_resp, "status_code", None),
+ if not space_id:
+ space_resp = await client.room_create(
+ name=f"Lambda — {display_name}",
+ space=True,
+ visibility=RoomVisibility.private,
+ invite=[matrix_user_id],
)
- return
- space_id = space_resp.room_id
+ if isinstance(space_resp, RoomCreateError):
+ logger.error(
+ "space creation failed",
+ user=matrix_user_id,
+ error=getattr(space_resp, "status_code", None),
+ )
+ raise RuntimeError("Не удалось создать Space.")
+ space_id = space_resp.room_id
+ user_meta["space_id"] = space_id
+ await set_user_meta(store, matrix_user_id, user_meta)
+
+ next_chat_index = int(user_meta.get("next_chat_index", 1))
+ chat_id = f"C{next_chat_index}"
+ platform_chat_id = await next_platform_chat_id(store)
+ room_name = room_name_override or _default_room_name(chat_id)
+
+ agent_id = None
+ agent_assignment = "none"
+ if registry is not None:
+ assignment = registry.resolve_agent_for_user(matrix_user_id)
+ agent_id = assignment.agent_id
+ agent_assignment = assignment.source
chat_resp = await client.room_create(
- name="Чат 1",
+ name=room_name,
visibility=RoomVisibility.private,
is_direct=False,
invite=[matrix_user_id],
@@ -62,7 +111,7 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
user=matrix_user_id,
error=getattr(chat_resp, "status_code", None),
)
- return
+ raise RuntimeError("Не удалось создать рабочий чат.")
chat_room_id = chat_resp.room_id
await client.room_put_state(
@@ -72,10 +121,8 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
state_key=chat_room_id,
)
- chat_id = await next_chat_id(store, matrix_user_id)
-
- user_meta = await get_user_meta(store, matrix_user_id) or {}
user_meta["space_id"] = space_id
+ user_meta["next_chat_index"] = next_chat_index + 1
await set_user_meta(store, matrix_user_id, user_meta)
await set_room_meta(
@@ -84,9 +131,12 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
{
"room_type": "chat",
"chat_id": chat_id,
- "display_name": "Чат 1",
+ "display_name": room_name,
"matrix_user_id": matrix_user_id,
"space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+ "agent_id": agent_id,
+ "agent_assignment": agent_assignment,
},
)
await chat_mgr.get_or_create(
@@ -94,15 +144,142 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
chat_id=chat_id,
platform="matrix",
surface_ref=chat_room_id,
- name="Чат 1",
+ name=room_name,
)
+ return {
+ "user": user,
+ "space_id": space_id,
+ "chat_room_id": chat_room_id,
+ "chat_id": chat_id,
+ "room_name": room_name,
+ "agent_assignment": agent_assignment,
+ "agent_id": agent_id,
+ }
+
+
+async def restore_workspace_access(
+ client: Any,
+ matrix_user_id: str,
+ display_name: str,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ registry: AgentRegistry | None = None,
+) -> dict:
+ user_meta = await get_user_meta(store, matrix_user_id) or {}
+ space_id = user_meta.get("space_id")
+ if not space_id:
+ created = await provision_workspace_chat(
+ client,
+ matrix_user_id,
+ display_name,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ room_name_override="Чат 1",
+ registry=registry,
+ )
+ return {**created, "reinvited_rooms": [], "created_new_chat": True}
+
+ await auth_mgr.confirm(matrix_user_id)
+ await _invite_if_possible(client, space_id, matrix_user_id)
+
+ chats = await chat_mgr.list_active(matrix_user_id)
+ if not chats:
+ created = await provision_workspace_chat(
+ client,
+ matrix_user_id,
+ display_name,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ registry=registry,
+ )
+ return {**created, "reinvited_rooms": [], "created_new_chat": True}
+
+ reinvited_rooms = []
+ for chat in chats:
+ if chat.surface_ref:
+ if await _invite_if_possible(client, chat.surface_ref, matrix_user_id):
+ reinvited_rooms.append(chat.surface_ref)
+
+ return {
+ "space_id": space_id,
+ "reinvited_rooms": reinvited_rooms,
+ "created_new_chat": False,
+ }
+
+
+async def handle_invite(
+ client: Any,
+ room: Any,
+ event: Any,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ registry: AgentRegistry | None = None,
+) -> None:
+ matrix_user_id = getattr(event, "sender", "")
+ display_name = getattr(room, "display_name", None) or matrix_user_id
+
+ await client.join(room.room_id)
+
+ existing = await get_user_meta(store, matrix_user_id)
+ if existing and existing.get("space_id"):
+ restored = await restore_workspace_access(
+ client,
+ matrix_user_id,
+ display_name,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ registry=registry,
+ )
+ body = "Я отправил повторные приглашения в пространство Lambda и рабочие чаты."
+ if restored.get("created_new_chat"):
+ body = (
+ f"Создал новый рабочий чат {restored['room_name']} "
+ f"({restored['chat_id']}) и отправил приглашение."
+ )
+ if restored.get("agent_assignment") == "default":
+ body = f"{body}\n\n{default_agent_notice()}"
+ await client.room_send(
+ room.room_id,
+ "m.room.message",
+ {"msgtype": "m.text", "body": body},
+ )
+ return
+
+ try:
+ created = await provision_workspace_chat(
+ client,
+ matrix_user_id,
+ display_name,
+ platform,
+ store,
+ auth_mgr,
+ chat_mgr,
+ room_name_override="Чат 1",
+ registry=registry,
+ )
+ except RuntimeError as exc:
+ logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc))
+ return
+
welcome = (
- f"Привет, {user.display_name or matrix_user_id}! Пиши — я здесь.\n\n"
- "Команды: !new · !chats · !rename · !archive · !skills · !soul · !safety · !settings"
+ f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n"
+ "Команды: !new · !chats · !rename · !archive · !clear · !help"
)
+ if created.get("agent_assignment") == "default":
+ welcome = f"{welcome}\n\n{default_agent_notice()}"
await client.room_send(
- chat_room_id,
+ created["chat_room_id"],
"m.room.message",
{"msgtype": "m.text", "body": welcome},
)
diff --git a/adapter/matrix/handlers/chat.py b/adapter/matrix/handlers/chat.py
index c5096ff..645e9cd 100644
--- a/adapter/matrix/handlers/chat.py
+++ b/adapter/matrix/handlers/chat.py
@@ -1,12 +1,20 @@
from __future__ import annotations
-from typing import Any, Awaitable, Callable
+from collections.abc import Awaitable, Callable
+from typing import Any
import structlog
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
-from adapter.matrix.store import get_user_meta, next_chat_id, set_room_meta
+from adapter.matrix.agent_registry import AgentRegistry
+from adapter.matrix.handlers.auth import default_agent_notice
+from adapter.matrix.store import (
+ get_user_meta,
+ next_chat_id,
+ next_platform_chat_id,
+ set_room_meta,
+)
from core.protocol import IncomingCommand, OutgoingMessage
logger = structlog.get_logger(__name__)
@@ -42,6 +50,7 @@ async def _fallback_new_chat(
def make_handle_new_chat(
client: Any | None,
store: Any | None,
+ registry: AgentRegistry | None = None,
) -> Callable[..., Awaitable[list]]:
async def handle_new_chat(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
@@ -69,6 +78,7 @@ def make_handle_new_chat(
name = " ".join(event.args).strip() if event.args else ""
chat_id = await next_chat_id(store, event.user_id)
+ platform_chat_id = await next_platform_chat_id(store)
room_name = name or f"Чат {chat_id}"
response = await client.room_create(
@@ -97,17 +107,24 @@ def make_handle_new_chat(
state_key=room_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,
- },
- )
+ agent_id = None
+ agent_assignment = "none"
+ if registry is not None:
+ assignment = registry.resolve_agent_for_user(event.user_id)
+ agent_id = assignment.agent_id
+ agent_assignment = assignment.source
+
+ room_meta: dict = {
+ "room_type": "chat",
+ "chat_id": chat_id,
+ "display_name": room_name,
+ "matrix_user_id": event.user_id,
+ "space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+ "agent_id": agent_id,
+ "agent_assignment": agent_assignment,
+ }
+ await set_room_meta(store, room_id, room_meta)
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=chat_id,
@@ -115,10 +132,13 @@ def make_handle_new_chat(
surface_ref=room_id,
name=room_name,
)
+ text = f"Создан чат: {ctx.display_name} ({ctx.chat_id})"
+ if agent_assignment == "default":
+ text = f"{text}\n\n{default_agent_notice()}"
return [
OutgoingMessage(
chat_id=event.chat_id,
- text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})",
+ text=text,
)
]
@@ -150,7 +170,10 @@ def make_handle_rename(
return [
OutgoingMessage(
chat_id=event.chat_id,
- text="Этот чат не найден в локальном состоянии бота. Открой зарегистрированную комнату или создай новый чат через !new.",
+ text=(
+ "Этот чат не найден в локальном состоянии бота. "
+ "Открой зарегистрированную комнату или создай новый чат через !new."
+ ),
)
]
@@ -180,7 +203,10 @@ def make_handle_archive(
return [
OutgoingMessage(
chat_id=event.chat_id,
- text="Этот чат не найден в локальном состоянии бота. Создай новый чат через !new.",
+ text=(
+ "Этот чат не найден в локальном состоянии бота. "
+ "Создай новый чат через !new."
+ ),
)
]
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id)
diff --git a/adapter/matrix/handlers/context_commands.py b/adapter/matrix/handlers/context_commands.py
new file mode 100644
index 0000000..121d76b
--- /dev/null
+++ b/adapter/matrix/handlers/context_commands.py
@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import re
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+
+import httpx
+import structlog
+
+from adapter.matrix.store import (
+ get_room_meta,
+ next_platform_chat_id,
+ set_load_pending,
+ set_platform_chat_id,
+)
+from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage
+
+if TYPE_CHECKING:
+ from core.store import StateStore
+ from sdk.prototype_state import PrototypeStateStore
+
+logger = structlog.get_logger(__name__)
+
+SAVE_PROMPT = (
+ "Summarize our conversation and save to /workspace/contexts/{name}.md. "
+ "Reply only with: Saved: {name}"
+)
+LOAD_PROMPT = (
+ "Load context from /workspace/contexts/{name}.md and use it as background "
+ "for our conversation. Reply: Loaded: {name}"
+)
+_VALID_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
+
+
+def _sanitize_session_name(raw_name: str) -> str | None:
+ name = raw_name.strip()
+ if not name or not _VALID_NAME.fullmatch(name):
+ return None
+ return name
+
+
+async def _resolve_room_id(event: IncomingCommand, chat_mgr) -> str:
+ if chat_mgr is None:
+ return event.chat_id
+ ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id)
+ if ctx is not None and ctx.surface_ref:
+ return ctx.surface_ref
+ return event.chat_id
+
+
+async def _resolve_context_scope(
+ event: IncomingCommand,
+ store: StateStore,
+ chat_mgr,
+) -> tuple[str, str | None]:
+ room_id = await _resolve_room_id(event, chat_mgr)
+ room_meta = await get_room_meta(store, room_id)
+ platform_chat_id = room_meta.get("platform_chat_id") if room_meta else None
+ return room_id, platform_chat_id
+
+
+async def _require_platform_context(
+ event: IncomingCommand,
+ store: StateStore,
+ chat_mgr,
+) -> tuple[str, str]:
+ room_id, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr)
+ if not platform_chat_id:
+ raise RuntimeError(f"matrix room context is incomplete: {room_id}")
+ return room_id, platform_chat_id
+
+
+def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore):
+ async def handle_save(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ if event.args:
+ name = _sanitize_session_name(event.args[0])
+ if name is None:
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text="Имя сохранения может содержать только буквы, цифры, _ и -.",
+ )
+ ]
+ else:
+ name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
+
+ try:
+ await platform.send_message(
+ event.user_id,
+ event.chat_id,
+ SAVE_PROMPT.format(name=name),
+ )
+ except Exception as exc:
+ logger.warning("save_agent_call_failed", error=str(exc))
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")]
+
+ try:
+ _, platform_chat_id = await _require_platform_context(event, store, chat_mgr)
+ except RuntimeError as exc:
+ logger.warning("save_context_incomplete", error=str(exc))
+ return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")]
+
+ await prototype_state.add_saved_session(
+ event.user_id,
+ name,
+ source_context_id=platform_chat_id,
+ )
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text=f"Запрос на сохранение отправлен агенту: {name}",
+ )
+ ]
+
+ return handle_save
+
+
+def make_handle_load(store: StateStore, prototype_state: PrototypeStateStore):
+ async def handle_load(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ sessions = await prototype_state.list_saved_sessions(event.user_id)
+ if not sessions:
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text="Нет сохранённых сессий. Используй !save [имя].",
+ )
+ ]
+
+ room_id, _ = await _resolve_context_scope(event, store, chat_mgr)
+ lines = ["Сохранённые сессии:"]
+ for index, session in enumerate(sessions, start=1):
+ created = session.get("created_at", "")[:10]
+ lines.append(f" {index}. {session['name']} ({created})")
+ lines.append("")
+ lines.append("Введи номер или 0 / !cancel для отмены.")
+
+ await set_load_pending(store, event.user_id, room_id, {"saves": sessions})
+ return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
+
+ return handle_load
+
+
+def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore):
+ async def handle_reset(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ try:
+ room_id, old_chat_id = await _require_platform_context(event, store, chat_mgr)
+ except RuntimeError as exc:
+ logger.warning("clear_context_incomplete", error=str(exc))
+ return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")]
+
+ new_chat_id = await next_platform_chat_id(store)
+ await set_platform_chat_id(store, room_id, new_chat_id)
+
+ disconnect = getattr(platform, "disconnect_chat", None)
+ if callable(disconnect):
+ await disconnect(old_chat_id)
+
+ await prototype_state.clear_current_session(old_chat_id)
+ await prototype_state.clear_current_session(new_chat_id)
+
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text="Контекст сброшен. Агент не помнит предыдущий разговор.",
+ )
+ ]
+
+ return handle_reset
+
+
+async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]:
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.post(f"{agent_base_url}/reset", timeout=5.0)
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
+ logger.warning("reset_endpoint_unreachable", error=str(exc))
+ return [
+ OutgoingMessage(
+ chat_id=chat_id,
+ text="Reset endpoint недоступен. Обратитесь к администратору.",
+ )
+ ]
+
+ if response.status_code == 404:
+ return [
+ OutgoingMessage(
+ chat_id=chat_id,
+ text="Reset endpoint недоступен. Обратитесь к администратору.",
+ )
+ ]
+ return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")]
+
+
+def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore):
+ async def handle_context(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+ ) -> list[OutgoingEvent]:
+ try:
+ _, platform_chat_id = await _require_platform_context(event, store, chat_mgr)
+ except RuntimeError as exc:
+ logger.warning("context_scope_incomplete", error=str(exc))
+ return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")]
+
+ current_session = await prototype_state.get_current_session(platform_chat_id)
+ tokens_used = await prototype_state.get_last_tokens_used(platform_chat_id)
+ sessions = await prototype_state.list_saved_sessions(event.user_id)
+
+ lines = [
+ "Контекст:",
+ f" Контекст чата: {platform_chat_id}",
+ f" Сессия: {current_session or 'не загружена'}",
+ f" Токены (последний ответ): {tokens_used}",
+ f" Сохранения ({len(sessions)}):",
+ ]
+ if sessions:
+ for session in sessions:
+ created = session.get("created_at", "")[:10]
+ lines.append(f" - {session['name']} ({created})")
+ else:
+ lines.append(" (нет)")
+
+ return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
+
+ return handle_context
diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py
index a63df02..59bee6b 100644
--- a/adapter/matrix/handlers/settings.py
+++ b/adapter/matrix/handlers/settings.py
@@ -1,8 +1,6 @@
from __future__ import annotations
-from adapter.matrix.reactions import build_skills_text
-from core.protocol import IncomingCommand, OutgoingMessage, SettingsAction
-
+from core.protocol import IncomingCommand, OutgoingMessage
HELP_TEXT = "\n".join(
[
@@ -12,186 +10,87 @@ HELP_TEXT = "\n".join(
"!chats список активных чатов",
"!rename <название> переименовать текущий чат",
"!archive архивировать текущий чат",
- "!settings общий обзор настроек",
- "!skills список навыков",
- "!soul [поле значение] показать или изменить личность",
- "!safety [триггер on/off] показать или изменить безопасность",
- "!status краткий статус",
- "!whoami показать ваш id",
+ "",
+ "!clear сбросить контекст текущего чата",
+ "",
+ "!list показать файлы в очереди",
+ "!remove удалить файл из очереди",
+ "!remove all очистить очередь файлов",
+ "",
"!yes / !no подтвердить или отменить действие",
+ "!help эта справка",
]
)
-def _render_mapping(title: str, data: dict | None) -> str:
- data = data or {}
- lines = [title]
- if not data:
- lines.append("Нет данных.")
- else:
- for key, value in data.items():
- lines.append(f"• {key}: {value}")
- return "\n".join(lines)
-
-
-def _parse_bool(value: str) -> bool:
- return value.lower() in {"1", "true", "yes", "on", "enable", "enabled"}
+MVP_UNAVAILABLE_TEXT = (
+ "Эта команда скрыта в MVP и сейчас недоступна. "
+ "Используй !help для списка поддерживаемых команд."
+)
async def handle_settings(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- settings = await settings_mgr.get(event.user_id)
- chats = await chat_mgr.list_active(event.user_id)
-
- skills_lines = []
- for name, enabled in settings.skills.items():
- state = "on" if enabled else "off"
- skills_lines.append(f" {state} {name}")
- skills_text = "\n".join(skills_lines) if skills_lines else " нет навыков"
-
- soul_lines = []
- for key, value in (settings.soul or {}).items():
- soul_lines.append(f" {key}: {value}")
- soul_text = "\n".join(soul_lines) if soul_lines else " по умолчанию"
-
- safety_lines = []
- for key, value in (settings.safety or {}).items():
- state = "on" if value else "off"
- safety_lines.append(f" {state} {key}")
- safety_text = "\n".join(safety_lines) if safety_lines else " по умолчанию"
-
- chat_lines = [f" {chat.display_name} ({chat.chat_id})" for chat in chats]
- chats_text = "\n".join(chat_lines) if chat_lines else " нет активных чатов"
-
- dashboard = "\n".join(
- [
- "Настройки",
- "",
- "Скиллы:",
- skills_text,
- "",
- "Личность:",
- soul_text,
- "",
- "Безопасность:",
- safety_text,
- "",
- f"Активные чаты ({len(chats)}):",
- chats_text,
- ]
- )
-
- return [OutgoingMessage(chat_id=event.chat_id, text=dashboard)]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
-async def handle_help(
- event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
-) -> list:
+async def handle_help(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
return [OutgoingMessage(chat_id=event.chat_id, text=HELP_TEXT)]
async def handle_settings_skills(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- settings = await settings_mgr.get(event.user_id)
- return [OutgoingMessage(chat_id=event.chat_id, text=build_skills_text(settings))]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_settings_connectors(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- settings = await settings_mgr.get(event.user_id)
- return [
- OutgoingMessage(
- chat_id=event.chat_id, text=_render_mapping("🔗 Коннекторы", settings.connectors)
- )
- ]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_settings_soul(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- if len(event.args) >= 2:
- field = event.args[0]
- value = " ".join(event.args[1:])
- await settings_mgr.apply(
- event.user_id,
- SettingsAction(action="set_soul", payload={"field": field, "value": value}),
- )
- return [
- OutgoingMessage(chat_id=event.chat_id, text=f"Личность обновлена: {field} = {value}")
- ]
- settings = await settings_mgr.get(event.user_id)
- return [
- OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("🧠 Личность", settings.soul))
- ]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_settings_safety(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- if len(event.args) >= 2:
- trigger = event.args[0]
- enabled = _parse_bool(event.args[1])
- await settings_mgr.apply(
- event.user_id,
- SettingsAction(action="set_safety", payload={"trigger": trigger, "enabled": enabled}),
- )
- state = "включена" if enabled else "выключена"
- return [OutgoingMessage(chat_id=event.chat_id, text=f"Безопасность {trigger} {state}")]
- settings = await settings_mgr.get(event.user_id)
- return [
- OutgoingMessage(
- chat_id=event.chat_id, text=_render_mapping("🔒 Безопасность", settings.safety)
- )
- ]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_settings_plan(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- settings = await settings_mgr.get(event.user_id)
- return [OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("💳 План", settings.plan))]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_settings_status(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- chats = await chat_mgr.list_active(event.user_id)
- settings = await settings_mgr.get(event.user_id)
- text = "\n".join(
- [
- "📊 Статус",
- f"Активных чатов: {len(chats)}",
- f"Скиллов: {len(settings.skills)}",
- f"Коннекторов: {len(settings.connectors)}",
- ]
- )
- return [OutgoingMessage(chat_id=event.chat_id, text=text)]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_settings_whoami(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
- return [OutgoingMessage(chat_id=event.chat_id, text=f"👤 {event.platform}:{event.user_id}")]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_toggle_skill(event, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
- settings = await settings_mgr.get(event.user_id)
- keys = list(settings.skills.keys())
- skill = event.payload.get("skill")
- if not skill:
- idx = event.payload.get("skill_index")
- if isinstance(idx, int) and 1 <= idx <= len(keys):
- skill = keys[idx - 1]
- if not skill:
- return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не удалось определить навык.")]
+ return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
- enabled = not bool(settings.skills.get(skill, False))
- await settings_mgr.apply(
- event.user_id,
- SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled}),
- )
- state = "включён" if enabled else "выключен"
- return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")]
+
+async def handle_unknown_command(
+ event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
+) -> list:
+ return [
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text="Неизвестная команда. Используй !help для списка поддерживаемых команд.",
+ )
+ ]
diff --git a/adapter/matrix/reconciliation.py b/adapter/matrix/reconciliation.py
new file mode 100644
index 0000000..835bd5d
--- /dev/null
+++ b/adapter/matrix/reconciliation.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+from adapter.matrix.store import (
+ get_room_meta,
+ get_user_meta,
+ next_platform_chat_id,
+ set_room_meta,
+ set_user_meta,
+)
+
+_CHAT_ID_PATTERNS = (
+ re.compile(r"\bC(?P\d+)\b", re.IGNORECASE),
+ re.compile(r"^Чат\s+(?P\d+)$", re.IGNORECASE),
+)
+
+
+@dataclass(slots=True)
+class ReconciliationResult:
+ recovered_rooms: int = 0
+ repaired_rooms: int = 0
+ backfilled_platform_chat_ids: int = 0
+
+
+def _room_name(room: object) -> str | None:
+ for attr in ("name", "display_name"):
+ value = getattr(room, attr, None)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+def _chat_id_from_room(room: object, existing_meta: dict | None) -> str | None:
+ chat_id = (existing_meta or {}).get("chat_id")
+ if isinstance(chat_id, str) and chat_id:
+ return chat_id
+
+ name = _room_name(room)
+ if not name:
+ return None
+
+ for pattern in _CHAT_ID_PATTERNS:
+ match = pattern.search(name)
+ if match:
+ return f"C{int(match.group('index'))}"
+ return None
+
+
+def _space_id_for_room(
+ room: object, rooms_by_id: dict[str, object], existing_meta: dict | None
+) -> str | None:
+ existing_space_id = (existing_meta or {}).get("space_id")
+ if isinstance(existing_space_id, str) and existing_space_id:
+ return existing_space_id
+
+ parents = getattr(room, "parents", None)
+ if not parents:
+ parents = getattr(room, "space_parents", None)
+ if not parents:
+ return None
+
+ for parent_id in parents:
+ parent = rooms_by_id.get(parent_id)
+ if parent is None:
+ continue
+ if getattr(parent, "room_type", None) == "m.space" or getattr(parent, "children", None):
+ return parent_id
+ return parent_id
+ return None
+
+
+def _matrix_user_id_for_room(
+ room: object, bot_user_id: str | None, existing_meta: dict | None
+) -> str | None:
+ existing_user_id = (existing_meta or {}).get("matrix_user_id")
+ if isinstance(existing_user_id, str) and existing_user_id:
+ return existing_user_id
+
+ users = getattr(room, "users", None) or {}
+ for user_id in users:
+ if user_id != bot_user_id:
+ return user_id
+ return None
+
+
+async def reconcile_startup_state(client: object, runtime: object) -> ReconciliationResult:
+ rooms_by_id = getattr(client, "rooms", None) or {}
+ bot_user_id = getattr(client, "user_id", None)
+ result = ReconciliationResult()
+ max_chat_index_by_user: dict[str, int] = {}
+ recovered_space_by_user: dict[str, str] = {}
+
+ for room_id, room in rooms_by_id.items():
+ if getattr(room, "room_type", None) == "m.space":
+ continue
+
+ existing_meta = await get_room_meta(runtime.store, room_id)
+ if existing_meta and existing_meta.get("redirect_room_id"):
+ continue
+
+ space_id = _space_id_for_room(room, rooms_by_id, existing_meta)
+ chat_id = _chat_id_from_room(room, existing_meta)
+ matrix_user_id = _matrix_user_id_for_room(room, bot_user_id, existing_meta)
+ if not space_id or not chat_id or not matrix_user_id:
+ continue
+
+ recovered_space_by_user[matrix_user_id] = space_id
+ chat_index = int(chat_id[1:])
+ max_chat_index_by_user[matrix_user_id] = max(
+ max_chat_index_by_user.get(matrix_user_id, 0),
+ chat_index,
+ )
+
+ display_name = (existing_meta or {}).get("display_name") or _room_name(room) or chat_id
+ room_meta = dict(existing_meta or {})
+ room_meta.update(
+ {
+ "room_type": "chat",
+ "chat_id": chat_id,
+ "display_name": display_name,
+ "matrix_user_id": matrix_user_id,
+ "space_id": space_id,
+ }
+ )
+
+ if not room_meta.get("platform_chat_id"):
+ room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store)
+ result.backfilled_platform_chat_ids += 1
+
+ if not room_meta.get("agent_id"):
+ registry = getattr(runtime, "registry", None)
+ if registry is not None:
+ assignment = registry.resolve_agent_for_user(matrix_user_id)
+ if assignment.agent_id:
+ room_meta["agent_id"] = assignment.agent_id
+ room_meta["agent_assignment"] = assignment.source
+ else:
+ registry = getattr(runtime, "registry", None)
+ if registry is not None:
+ assignment = registry.resolve_agent_for_user(matrix_user_id)
+ if assignment.source == "configured" and (
+ room_meta.get("agent_id") != assignment.agent_id
+ or room_meta.get("agent_assignment") != "configured"
+ ):
+ room_meta["agent_id"] = assignment.agent_id
+ room_meta["agent_assignment"] = "configured"
+ elif (
+ assignment.source == "default"
+ and room_meta.get("agent_id") == assignment.agent_id
+ and not room_meta.get("agent_assignment")
+ ):
+ room_meta["agent_assignment"] = "default"
+
+ if existing_meta is None:
+ result.recovered_rooms += 1
+ elif room_meta != existing_meta:
+ result.repaired_rooms += 1
+
+ await set_room_meta(runtime.store, room_id, room_meta)
+ await runtime.auth_mgr.confirm(matrix_user_id)
+ await runtime.chat_mgr.get_or_create(
+ user_id=matrix_user_id,
+ chat_id=chat_id,
+ platform="matrix",
+ surface_ref=room_id,
+ name=display_name,
+ )
+
+ for matrix_user_id, recovered_space_id in recovered_space_by_user.items():
+ user_meta = dict(await get_user_meta(runtime.store, matrix_user_id) or {})
+ user_meta["space_id"] = user_meta.get("space_id") or recovered_space_id
+ next_chat_index = max_chat_index_by_user[matrix_user_id] + 1
+ user_meta["next_chat_index"] = max(
+ int(user_meta.get("next_chat_index", 1)), next_chat_index
+ )
+ await set_user_meta(runtime.store, matrix_user_id, user_meta)
+
+ return result
diff --git a/adapter/matrix/routed_platform.py b/adapter/matrix/routed_platform.py
new file mode 100644
index 0000000..3f9adc8
--- /dev/null
+++ b/adapter/matrix/routed_platform.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import os
+from collections.abc import AsyncIterator, Mapping
+
+import structlog
+
+from adapter.matrix.store import get_room_meta
+from core.chat import ChatManager
+from core.store import StateStore
+from sdk.interface import (
+ Attachment,
+ MessageChunk,
+ MessageResponse,
+ PlatformClient,
+ PlatformError,
+ User,
+ UserSettings,
+)
+
+logger = structlog.get_logger(__name__)
+
+
+def _ws_debug_enabled() -> bool:
+ value = os.environ.get("SURFACES_DEBUG_WS", "")
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+class RoutedPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ *,
+ chat_mgr: ChatManager,
+ store: StateStore,
+ delegates: Mapping[str, PlatformClient],
+ ) -> None:
+ if not delegates:
+ raise ValueError("RoutedPlatformClient requires at least one delegate")
+ self._chat_mgr = chat_mgr
+ self._store = store
+ self._delegates = dict(delegates)
+ self._default_client = next(iter(self._delegates.values()))
+ self._prototype_state = getattr(self._default_client, "_prototype_state", None)
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ return await self._default_client.get_or_create_user(
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id)
+ return await delegate.send_message(user_id, platform_chat_id, text, attachments)
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id)
+ async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
+ yield chunk
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ return await self._default_client.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action) -> None:
+ await self._default_client.update_settings(user_id, action)
+
+ async def close(self) -> None:
+ for delegate in self._delegates.values():
+ close = getattr(delegate, "close", None)
+ if callable(close):
+ await close()
+
+ async def _resolve_delegate(
+ self, user_id: str, local_chat_id: str
+ ) -> tuple[PlatformClient, str]:
+ chat = await self._chat_mgr.get(local_chat_id, user_id)
+ if chat is None:
+ raise PlatformError(
+ f"unknown matrix chat id: {local_chat_id}",
+ code="MATRIX_CHAT_NOT_FOUND",
+ )
+
+ room_meta = await get_room_meta(self._store, chat.surface_ref)
+ if room_meta is None:
+ raise PlatformError(
+ f"matrix room is not bound: {chat.surface_ref}",
+ code="MATRIX_ROOM_NOT_BOUND",
+ )
+
+ agent_id = room_meta.get("agent_id")
+ platform_chat_id = room_meta.get("platform_chat_id")
+ if not agent_id or not platform_chat_id:
+ raise PlatformError(
+ f"matrix room routing is incomplete: {chat.surface_ref}",
+ code="MATRIX_ROUTE_INCOMPLETE",
+ )
+
+ delegate = self._delegates.get(str(agent_id))
+ if delegate is None:
+ raise PlatformError(
+ f"unknown matrix agent id: {agent_id}",
+ code="MATRIX_AGENT_NOT_FOUND",
+ )
+
+ if _ws_debug_enabled():
+ logger.warning(
+ "matrix_route_resolved",
+ user_id=user_id,
+ local_chat_id=local_chat_id,
+ surface_ref=chat.surface_ref,
+ agent_id=str(agent_id),
+ platform_chat_id=str(platform_chat_id),
+ delegate_type=type(delegate).__name__,
+ )
+
+ return delegate, str(platform_chat_id)
diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py
index 30ee076..8ecd557 100644
--- a/adapter/matrix/store.py
+++ b/adapter/matrix/store.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+import asyncio
+from weakref import WeakValueDictionary
+
from core.store import StateStore
ROOM_META_PREFIX = "matrix_room:"
@@ -7,6 +10,12 @@ USER_META_PREFIX = "matrix_user:"
ROOM_STATE_PREFIX = "matrix_state:"
SKILLS_MSG_PREFIX = "matrix_skills_msg:"
PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
+LOAD_PENDING_PREFIX = "matrix_load_pending:"
+RESET_PENDING_PREFIX = "matrix_reset_pending:"
+STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"
+PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
+_STAGED_ATTACHMENTS_LOCKS: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary()
+_PLATFORM_CHAT_SEQ_LOCK = asyncio.Lock()
async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
@@ -17,6 +26,17 @@ async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None:
await store.set(f"{ROOM_META_PREFIX}{room_id}", meta)
+async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None:
+ meta = await get_room_meta(store, room_id)
+ return meta.get("platform_chat_id") if meta else None
+
+
+async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None:
+ meta = dict(await get_room_meta(store, room_id) or {})
+ meta["platform_chat_id"] = platform_chat_id
+ await set_room_meta(store, room_id, meta)
+
+
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None:
return await store.get(f"{USER_META_PREFIX}{matrix_user_id}")
@@ -25,6 +45,12 @@ async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> N
await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta)
+async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
+ meta = dict(await get_room_meta(store, room_id) or {})
+ meta["agent_id"] = agent_id
+ await set_room_meta(store, room_id, meta)
+
+
async def get_room_state(store: StateStore, room_id: str) -> str:
data = await store.get(f"{ROOM_STATE_PREFIX}{room_id}")
return data["state"] if data else "idle"
@@ -51,16 +77,29 @@ async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
return f"C{index}"
+async def next_platform_chat_id(store: StateStore) -> str:
+ async with _PLATFORM_CHAT_SEQ_LOCK:
+ data = await store.get(PLATFORM_CHAT_SEQ_KEY)
+ index = int((data or {}).get("next_platform_chat_index", 1))
+ await store.set(
+ PLATFORM_CHAT_SEQ_KEY,
+ {"next_platform_chat_index": index + 1},
+ )
+ return str(index)
+
+
def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str:
if room_id is None:
return f"{PENDING_CONFIRM_PREFIX}{user_id}"
return f"{PENDING_CONFIRM_PREFIX}{user_id}:{room_id}"
+
async def get_pending_confirm(
store: StateStore, user_id: str, room_id: str | None = None
) -> dict | None:
return await store.get(_pending_confirm_key(user_id, room_id))
+
async def set_pending_confirm(
store: StateStore, user_id: str, room_id: str | dict, meta: dict | None = None
) -> None:
@@ -74,3 +113,95 @@ async def clear_pending_confirm(
store: StateStore, user_id: str, room_id: str | None = None
) -> None:
await store.delete(_pending_confirm_key(user_id, room_id))
+
+
+def _load_pending_key(user_id: str, room_id: str) -> str:
+ return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}"
+
+
+async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
+ return await store.get(_load_pending_key(user_id, room_id))
+
+
+async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
+ await store.set(_load_pending_key(user_id, room_id), data)
+
+
+async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None:
+ await store.delete(_load_pending_key(user_id, room_id))
+
+
+def _reset_pending_key(user_id: str, room_id: str) -> str:
+ return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}"
+
+
+async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
+ return await store.get(_reset_pending_key(user_id, room_id))
+
+
+async def set_reset_pending(
+ store: StateStore,
+ user_id: str,
+ room_id: str,
+ data: dict,
+) -> None:
+ await store.set(_reset_pending_key(user_id, room_id), data)
+
+
+async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None:
+ await store.delete(_reset_pending_key(user_id, room_id))
+
+
+def _staged_attachments_key(room_id: str, user_id: str) -> str:
+ return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}"
+
+
+def _staged_attachments_lock(room_id: str, user_id: str) -> asyncio.Lock:
+ key = _staged_attachments_key(room_id, user_id)
+ lock = _STAGED_ATTACHMENTS_LOCKS.get(key)
+ if lock is None:
+ lock = asyncio.Lock()
+ _STAGED_ATTACHMENTS_LOCKS[key] = lock
+ return lock
+
+
+async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
+ data = await store.get(_staged_attachments_key(room_id, user_id))
+ if not isinstance(data, dict):
+ return []
+
+ attachments = data.get("attachments")
+ if not isinstance(attachments, list):
+ return []
+
+ return [attachment for attachment in attachments if isinstance(attachment, dict)]
+
+
+async def add_staged_attachment(
+ store: StateStore, room_id: str, user_id: str, attachment: dict
+) -> None:
+ async with _staged_attachments_lock(room_id, user_id):
+ attachments = await get_staged_attachments(store, room_id, user_id)
+ attachments.append(attachment)
+ await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments})
+
+
+async def remove_staged_attachment_at(
+ store: StateStore, room_id: str, user_id: str, index: int
+) -> dict | None:
+ async with _staged_attachments_lock(room_id, user_id):
+ attachments = await get_staged_attachments(store, room_id, user_id)
+ if index < 0 or index >= len(attachments):
+ return None
+
+ removed = attachments.pop(index)
+ if attachments:
+ await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments})
+ else:
+ await store.delete(_staged_attachments_key(room_id, user_id))
+ return removed
+
+
+async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
+ async with _staged_attachments_lock(room_id, user_id):
+ await store.delete(_staged_attachments_key(room_id, user_id))
diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml
new file mode 100644
index 0000000..84221eb
--- /dev/null
+++ b/config/matrix-agents.example.yaml
@@ -0,0 +1,44 @@
+# Agent registry for the Matrix bot.
+# Production target: one surface bot routes to 25-30 externally managed agents.
+# Keep adding entries with the same base_url/workspace_path pattern.
+#
+# user_agents: maps a Matrix user ID to an agent ID.
+# If a user is not listed, the bot uses the first agent from the list below.
+# Omit this section entirely for a single-agent setup.
+#
+# agents: list of available agents.
+# id — must match the agent ID known to the platform
+# label — human-readable name (shown in logs)
+# base_url — HTTP/WS URL of this agent's endpoint
+# (overrides the global AGENT_BASE_URL env var for this agent)
+# workspace_path — absolute path to this agent's workspace directory inside the bot container
+# (the bot saves incoming files directly here and reads outgoing files from here)
+# Example: /agents/0 means the bot mounts the shared volume at /agents/
+# and this agent's files live under /agents/0/
+
+user_agents:
+ "@user0:matrix.example.org": agent-0
+ "@user1:matrix.example.org": agent-1
+ "@user2:matrix.example.org": agent-2
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+
+ - id: agent-1
+ label: "Agent 1"
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
+
+ - id: agent-2
+ label: "Agent 2"
+ base_url: "http://lambda.coredump.ru:7000/agent_2/"
+ workspace_path: "/agents/2"
+
+ # Continue the same pattern through agent-29 for a 25-30 agent deployment:
+ # - id: agent-29
+ # label: "Agent 29"
+ # base_url: "http://lambda.coredump.ru:7000/agent_29/"
+ # workspace_path: "/agents/29"
diff --git a/config/matrix-agents.smoke.yaml b/config/matrix-agents.smoke.yaml
new file mode 100644
index 0000000..9b357fe
--- /dev/null
+++ b/config/matrix-agents.smoke.yaml
@@ -0,0 +1,10 @@
+agents:
+ - id: agent-0
+ label: "Smoke Agent 0"
+ base_url: "http://agent-proxy:7000/agent_0/"
+ workspace_path: "/agents/0"
+
+ - id: agent-1
+ label: "Smoke Agent 1"
+ base_url: "http://agent-proxy:7000/agent_1/"
+ workspace_path: "/agents/1"
diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml
new file mode 100644
index 0000000..3ab9366
--- /dev/null
+++ b/config/matrix-agents.yaml
@@ -0,0 +1,8 @@
+# Single-agent configuration for MVP deployment.
+# For multi-agent setup with per-user routing, see config/matrix-agents.example.yaml.
+
+agents:
+ - id: agent-1
+ label: Surface
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
diff --git a/core/handlers/message.py b/core/handlers/message.py
index 2edb87e..876754c 100644
--- a/core/handlers/message.py
+++ b/core/handlers/message.py
@@ -1,7 +1,35 @@
# core/handlers/message.py
from __future__ import annotations
-from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
+from core.protocol import Attachment, IncomingMessage, OutgoingMessage, OutgoingTyping
+
+
+def _infer_attachment_type(mime_type: str | None) -> str:
+ if not mime_type:
+ return "document"
+ if mime_type.startswith("image/"):
+ return "image"
+ if mime_type.startswith("audio/"):
+ return "audio"
+ if mime_type.startswith("video/"):
+ return "video"
+ return "document"
+
+
+def _to_core_attachments(raw: list) -> list[Attachment]:
+ result = []
+ for a in raw:
+ if isinstance(a, Attachment):
+ result.append(a)
+ else:
+ result.append(Attachment(
+ type=getattr(a, "type", None) or _infer_attachment_type(getattr(a, "mime_type", None)),
+ url=getattr(a, "url", None),
+ filename=getattr(a, "filename", None),
+ mime_type=getattr(a, "mime_type", None),
+ workspace_path=getattr(a, "workspace_path", None),
+ ))
+ return result
def _start_command(platform: str) -> str:
@@ -29,10 +57,15 @@ async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, s
user_id=event.user_id,
chat_id=event.chat_id,
text=event.text,
- attachments=[],
+ attachments=event.attachments,
)
return [
OutgoingTyping(chat_id=event.chat_id, is_typing=False),
- OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"),
+ OutgoingMessage(
+ chat_id=event.chat_id,
+ text=response.response,
+ parse_mode="markdown",
+ attachments=_to_core_attachments(getattr(response, "attachments", [])),
+ ),
]
diff --git a/core/protocol.py b/core/protocol.py
index 02a9f4a..7d6e25f 100644
--- a/core/protocol.py
+++ b/core/protocol.py
@@ -12,6 +12,7 @@ class Attachment:
content: bytes | None = None
filename: str | None = None
mime_type: str | None = None
+ workspace_path: str | None = None
@dataclass
diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml
new file mode 100644
index 0000000..88ff37b
--- /dev/null
+++ b/docker-compose.fullstack.yml
@@ -0,0 +1,61 @@
+services:
+ matrix-bot:
+ extends:
+ file: docker-compose.prod.yml
+ service: matrix-bot
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: development
+ args:
+ LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ tags:
+ - ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev}
+ environment:
+ AGENT_BASE_URL: http://platform-agent:8000
+ depends_on:
+ platform-agent:
+ condition: service_healthy
+
+ platform-agent:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ environment:
+ PYTHONUNBUFFERED: "1"
+ AGENT_ID: ${AGENT_ID:-matrix-dev}
+ PROVIDER_MODEL: ${PROVIDER_MODEL:-openai/gpt-4o-mini}
+ PROVIDER_URL: ${PROVIDER_URL:-}
+ PROVIDER_API_KEY: ${PROVIDER_API_KEY:-}
+ COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-}
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - agents:/workspace
+ command: >
+ sh -lc "
+ mkdir -p /workspace &&
+ chown -R agent:agent /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
+ "
+ ports:
+ - "8000:8000"
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
+ interval: 60s
+ timeout: 5s
+ retries: 5
+ start_period: 15s
+ restart: unless-stopped
+
+volumes:
+ agents:
+ name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
+ bot-state:
+ name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..2c7e942
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,26 @@
+services:
+ matrix-bot:
+ image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}"
+ environment:
+ MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-}
+ MATRIX_USER_ID: ${MATRIX_USER_ID:-}
+ MATRIX_PASSWORD: ${MATRIX_PASSWORD:-}
+ MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
+ MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real}
+ MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml}
+ AGENT_BASE_URL: ${AGENT_BASE_URL:-}
+ SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents}
+ MATRIX_DB_PATH: /app/state/lambda_matrix.db
+ MATRIX_STORE_PATH: /app/state/matrix_store
+ PYTHONUNBUFFERED: "1"
+ volumes:
+ - agents:/agents
+ - bot-state:/app/state
+ - ./config:/app/config:ro
+ restart: unless-stopped
+
+volumes:
+ agents:
+ name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
+ bot-state:
+ name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
diff --git a/docker-compose.smoke.timeout.yml b/docker-compose.smoke.timeout.yml
new file mode 100644
index 0000000..c8f4ba3
--- /dev/null
+++ b/docker-compose.smoke.timeout.yml
@@ -0,0 +1,18 @@
+services:
+ agent-proxy:
+ volumes:
+ - ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ agent-no-status:
+ condition: service_started
+
+ agent-no-status:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: production
+ args:
+ LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
+ environment:
+ PYTHONUNBUFFERED: "1"
+ command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml
new file mode 100644
index 0000000..ed4e8b8
--- /dev/null
+++ b/docker-compose.smoke.yml
@@ -0,0 +1,109 @@
+services:
+ surface-smoke:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: production
+ args:
+ LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
+ environment:
+ PYTHONUNBUFFERED: "1"
+ SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5}
+ volumes:
+ - agents:/agents
+ - ./config:/app/config:ro
+ depends_on:
+ agent-proxy:
+ condition: service_healthy
+ command: >
+ sh -lc "
+ python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5}
+ "
+
+ agent-proxy:
+ image: nginx:1.27-alpine
+ volumes:
+ - ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - nc -z 127.0.0.1 7000
+ interval: 2s
+ timeout: 2s
+ retries: 15
+ start_period: 2s
+ depends_on:
+ agent-0:
+ condition: service_healthy
+ agent-1:
+ condition: service_healthy
+ ports:
+ - "${SMOKE_PROXY_PORT:-7000}:7000"
+
+ agent-0:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ environment:
+ PYTHONUNBUFFERED: "1"
+ AGENT_ID: ${AGENT_0_ID:-agent-0}
+ PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
+ PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
+ PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - agents:/shared-agents
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+ command: >
+ sh -lc "
+ mkdir -p /shared-agents/0 &&
+ rm -rf /workspace &&
+ ln -s /shared-agents/0 /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
+ "
+
+ agent-1:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ environment:
+ PYTHONUNBUFFERED: "1"
+ AGENT_ID: ${AGENT_1_ID:-agent-1}
+ PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
+ PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
+ PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - agents:/shared-agents
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+ command: >
+ sh -lc "
+ mkdir -p /shared-agents/1 &&
+ rm -rf /workspace &&
+ ln -s /shared-agents/1 /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
+ "
+
+volumes:
+ agents:
+ name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c7323d0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+ platform-agent:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ env_file: .env
+ environment:
+ PYTHONUNBUFFERED: "1"
+ volumes:
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ - workspace:/workspace
+ command: >
+ sh -lc "
+ mkdir -p /workspace &&
+ chown -R agent:agent /workspace &&
+ exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
+ "
+ ports:
+ - "8000:8000"
+ restart: unless-stopped
+
+ matrix-bot:
+ build: .
+ env_file: .env
+ environment:
+ AGENT_BASE_URL: http://platform-agent:8000
+ SURFACES_WORKSPACE_DIR: /workspace
+ depends_on:
+ - platform-agent
+ volumes:
+ - workspace:/workspace
+ - ./config:/app/config:ro
+ restart: unless-stopped
+
+volumes:
+ workspace:
diff --git a/docker/nginx/smoke-agents-timeout.conf b/docker/nginx/smoke-agents-timeout.conf
new file mode 100644
index 0000000..03c7e79
--- /dev/null
+++ b/docker/nginx/smoke-agents-timeout.conf
@@ -0,0 +1,28 @@
+events {}
+
+http {
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ listen 7000;
+
+ location /agent_0/ {
+ proxy_pass http://agent-0:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
+ location /agent_1/ {
+ proxy_pass http://agent-no-status:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+ }
+}
diff --git a/docker/nginx/smoke-agents.conf b/docker/nginx/smoke-agents.conf
new file mode 100644
index 0000000..e3bcaab
--- /dev/null
+++ b/docker/nginx/smoke-agents.conf
@@ -0,0 +1,28 @@
+events {}
+
+http {
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ listen 7000;
+
+ location /agent_0/ {
+ proxy_pass http://agent-0:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
+ location /agent_1/ {
+ proxy_pass http://agent-1:8000/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+ }
+}
diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md
new file mode 100644
index 0000000..e838611
--- /dev/null
+++ b/docs/deploy-architecture.md
@@ -0,0 +1,197 @@
+# Deployment Architecture — Matrix Bot + Agents
+
+> Сформировано 2026-04-27 по итогам обсуждения с платформой.
+
+---
+
+## Compose Artifacts
+
+- **Production deploy:** `docker-compose.prod.yml`
+ Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`.
+ Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`.
+- **Internal full-stack E2E:** `docker-compose.fullstack.yml`
+ Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup.
+
+Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`.
+
+---
+
+## Топология
+
+```
+lambda.coredump.ru
+├── :7000 (reverse proxy, path-based routing)
+│ ├── /agent_0/ → agent_0 container
+│ ├── /agent_1/ → agent_1 container
+│ └── /agent_N/ → agent_N container
+│
+└── Matrix bot instance (один инстанс на всех)
+ └── volume /agents/ (shared с агентами)
+ ├── /agents/0/ ← workspace agent_0
+ ├── /agents/1/ ← workspace agent_1
+ └── /agents/N/
+```
+
+- **Один инстанс Matrix-бота** обслуживает всех пользователей.
+- **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance.
+- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу.
+
+---
+
+## Конфиг (два словаря)
+
+```yaml
+# config/matrix-agents.yaml
+
+user_agents:
+ "@user0:matrix.lambda.coredump.ru": agent-0
+ "@user1:matrix.lambda.coredump.ru": agent-1
+ "@user2:matrix.lambda.coredump.ru": agent-2
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+
+ - id: agent-1
+ label: "Agent 1"
+ base_url: "http://lambda.coredump.ru:7000/agent_1/"
+ workspace_path: "/agents/1"
+
+ - id: agent-2
+ label: "Agent 2"
+ base_url: "http://lambda.coredump.ru:7000/agent_2/"
+ workspace_path: "/agents/2"
+```
+
+- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка.
+- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi.
+- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume).
+ Бот сохраняет входящие файлы прямо в `{workspace_path}/`, читает исходящие из `{workspace_path}/`.
+- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
+
+## Surface Image Build Contract
+
+Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context.
+
+```bash
+docker login
+export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
+
+docker build --target production \
+ --build-arg LAMBDA_AGENT_API_REF=master \
+ -t "$SURFACES_BOT_IMAGE" .
+docker push "$SURFACES_BOT_IMAGE"
+```
+
+Published image:
+
+```text
+mput1/surfaces-bot:latest
+sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
+```
+
+`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
+
+Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image:
+
+```bash
+git+https://git.lambda.coredump.ru/platform/agent_api.git
+```
+
+Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK.
+
+---
+
+## Agent API (используем master ветку `platform/agent_api`)
+
+```python
+from lambda_agent_api.agent_api import AgentApi
+
+connected_agents: dict[tuple[str, int], AgentApi] = {}
+
+def on_agent_disconnect(agent: AgentApi):
+ connected_agents.pop((agent.id, agent.chat_id), None)
+
+async def on_message(matrix_user_id: str, matrix_room_id: str, text: str):
+ agent_id = get_agent_id_by_user(matrix_user_id) # из user_agents конфига
+ platform_chat_id = get_room_platform_chat_id(matrix_room_id)
+
+ agent = connected_agents.get((agent_id, platform_chat_id))
+ if not agent:
+ agent = AgentApi(
+ agent_id,
+ get_agent_base_url(agent_id), # ws://lambda.coredump.ru:7000/agent_0/
+ on_disconnect=on_agent_disconnect,
+ chat_id=platform_chat_id, # отдельный thread на Matrix room
+ )
+ await agent.connect()
+ connected_agents[(agent_id, platform_chat_id)] = agent
+
+ async for event in agent.send_message(text):
+ ...
+```
+
+**Параметры конструктора (master):**
+```python
+AgentApi(
+ agent_id: str,
+ base_url: str, # ws://host:port/agent_N/
+ chat_id: int = 0, # surfaces must supply per-room platform_chat_id
+ on_disconnect: callable,
+)
+```
+
+**Lifecycle:** агент автоматически отключается после нескольких минут бездействия.
+`on_disconnect` удаляет из пула → следующее сообщение создаёт новое соединение.
+
+---
+
+## Передача файлов
+
+### Пользователь → Агент (входящий файл)
+
+1. Matrix-бот получает файл от пользователя
+2. Сохраняет в workspace агента: `/agents/{N}/{filename}`
+3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext`
+4. Вызывает `agent.send_message(text, attachments=["filename"])`
+ — путь относительно `/workspace` агента
+
+### Агент → Пользователь (исходящий файл)
+
+1. Агент эмитит `MsgEventSendFile(path="report.pdf")`
+2. Matrix-бот читает файл: `/agents/{N}/report.pdf`
+3. Отправляет как Matrix file message пользователю
+
+**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен.
+
+---
+
+## Текущее состояние platform-agent (main)
+
+- Composio интегрирован в main (`#9-интеграция-composIO`)
+- Агент требует в `.env`: `AGENT_ID`, `COMPOSIO_API_KEY`
+- Backend: `IsolatedShellBackend` (main) / `CompositeBackend` (ветка `#19`, не merged)
+- Memory: `MemorySaver` — история слетает при рестарте контейнера (known limitation)
+
+---
+
+## platform-master (будущее, пока не используем)
+
+Ветка `feat/storage` реализует реальный Master-сервис:
+- `POST /api/v1/create {chat_id}` → поднимает/переиспользует sandbox-контейнер
+- TTL-based lifecycle (300с default, конфигурируемо)
+- `ChatStorage` — API для upload/download файлов через Master
+- Auth + p2p lease — вне текущего scope MVP
+
+**Для деплоя MVP используем статический конфиг без Master.**
+При готовности Master: `get_agent_url()` будет вызывать `POST /api/v1/create`, URL возвращается в ответе.
+
+---
+
+## Открытые вопросы
+
+- `platform-agent_api #9-clientside-tool-call` убирает `attachments` и `MsgEventSendFile` — пока используем master. Уточнить у платформы сроки мержа перед деплоем.
+- История при рестарте агента теряется — `platform-agent` использует `MemorySaver` (in-memory). Ограничение платформы.
+- `AGENT_ID` и `COMPOSIO_API_KEY` для каждого агент-контейнера — значения предоставляет платформа.
diff --git a/docs/matrix-direct-agent-prototype-ru.md b/docs/matrix-direct-agent-prototype-ru.md
new file mode 100644
index 0000000..8f1dcee
--- /dev/null
+++ b/docs/matrix-direct-agent-prototype-ru.md
@@ -0,0 +1,298 @@
+# Matrix Direct-Agent Prototype
+
+Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket.
+
+## Что сделали
+
+В этой ветке собран рабочий Matrix-only прототип с минимальным вмешательством в существующую архитектуру.
+
+Ключевая идея:
+- Matrix-адаптер и `core/` остаются на старом контракте `PlatformClient`
+- вместо `sdk/mock.py` можно включить `sdk/real.py`
+- `sdk/real.py` внутри разделяет две ответственности:
+ - `sdk/agent_session.py` — прямое общение с agent по WebSocket
+ - `sdk/prototype_state.py` — локальный user/settings state для прототипа
+
+Это позволило не переписывать Matrix-логику под нестабильный `platform/master` и при этом подключить живого агента вместо мока.
+
+## Что поменялось в `surfaces-bot`
+
+Добавлено:
+- `sdk/agent_session.py`
+- `sdk/prototype_state.py`
+- `sdk/real.py`
+- тесты для transport/state/real backend
+
+Изменено:
+- `adapter/matrix/bot.py`
+- `adapter/matrix/handlers/auth.py`
+- `README.md`
+- интеграционные и Matrix dispatcher тесты
+
+Функционально это дало:
+- переключение Matrix backend через env:
+ - `MATRIX_PLATFORM_BACKEND=mock`
+ - `MATRIX_PLATFORM_BACKEND=real`
+- прямую отправку текста в live agent через `AGENT_BASE_URL`
+- локальное хранение settings и user mapping
+- изоляцию backend memory по `thread_id`
+- исправление повторных invite: бот теперь сначала `join()`, а уже потом решает, нужно ли пере-провиженить Space/chat tree
+
+## Что поменяли в `platform-agent`
+
+Для прототипа потребовался минимальный локальный патч в клонированном `external/platform-agent`.
+
+Изменения:
+- `src/api/external.py`
+- `src/agent/service.py`
+
+Смысл патча:
+- agent больше не использует один общий hardcoded `thread_id="default"`
+- `thread_id` читается из query parameter WebSocket-соединения
+- дальше этот `thread_id` передаётся в config memory/checkpointer
+
+Локальный commit в clone:
+- `1dca2c1` — `feat: support websocket thread ids`
+
+Важно:
+- этот commit живёт в `external/platform-agent`
+- он не входит в git-историю `surfaces-bot`
+- если прототип должен запускаться у других людей без ручных патчей, этот commit надо отдельно запушить или повторить в platform repo
+
+## Текущая архитектура прототипа
+
+Поток сообщения сейчас такой:
+
+1. Matrix room event попадает в `adapter/matrix`
+2. адаптер переводит его в `IncomingMessage` / `IncomingCommand`
+3. `EventDispatcher` вызывает handler из `core/`
+4. handler вызывает `PlatformClient`
+5. при real backend это `RealPlatformClient`
+6. `RealPlatformClient` строит `thread_key`
+7. `AgentSessionClient` открывает WebSocket на `agent_ws/?thread_id=...`
+8. ответ агента возвращается обратно в Matrix
+
+Что остаётся локальным в v1:
+- `!settings`
+- `!skills`
+- `!soul`
+- `!safety`
+- user registration mapping
+
+Что реально идёт в живого агента:
+- обычные текстовые сообщения
+- память по чатам через `thread_id`
+
+## Ограничения прототипа
+
+Сейчас это не полный platform integration, а рабочий direct-agent prototype.
+
+Ограничения:
+- только текстовый чат
+- без attachments в agent
+- без async task callbacks/webhooks
+- без реального control-plane из `platform/master`
+- encrypted Matrix rooms пока не поддержаны
+- repeat invite не создаёт новую Space-структуру, если user уже был провиженен локально
+- backend/provider ошибки пока не везде деградируют в user-facing reply; часть ошибок всё ещё может уронить процесс surface
+
+## Как запускать
+
+Нужно поднять два процесса:
+- patched `platform-agent`
+- Matrix bot из `surfaces-bot`
+
+### 1. Подготовить `platform-agent`
+
+Локальный clone:
+- [external/platform-agent](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent)
+
+И связанный SDK clone:
+- [external/platform-agent_api](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api)
+
+Первичная подготовка:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+uv sync
+uv pip install --python .venv/bin/python -e ../platform-agent_api
+```
+
+Если у вас был активирован чужой venv, сначала сделайте:
+
+```bash
+deactivate
+```
+
+Иначе `uv pip install` может поставить пакет не в тот interpreter.
+
+### 2. Запустить agent backend
+
+Пример с OpenRouter:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+
+export PROVIDER_URL=https://openrouter.ai/api/v1
+export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY'
+export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b'
+
+uv run uvicorn src.main:app --host 0.0.0.0 --port 8000
+```
+
+После этого WebSocket endpoint должен быть доступен по:
+
+```text
+ws://127.0.0.1:8000/agent_ws/
+```
+
+### 3. Запустить Matrix bot
+
+В отдельном терминале:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+
+export MATRIX_PLATFORM_BACKEND=real
+export AGENT_BASE_URL=http://127.0.0.1:8000
+export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru
+export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru
+export MATRIX_PASSWORD='YOUR_PASSWORD'
+
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+Если всё ок, в логах будет что-то вроде:
+
+```text
+Matrix bot starting ...
+```
+
+## Точные команды
+
+Ниже команды в том виде, в котором реально поднимался рабочий прототип.
+
+### Platform / agent backend
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+deactivate 2>/dev/null || true
+uv sync
+uv pip install --python .venv/bin/python -e ../platform-agent_api
+
+export PROVIDER_URL=https://openrouter.ai/api/v1
+export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY'
+export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b'
+
+uv run uvicorn src.main:app --host 0.0.0.0 --port 8000
+```
+
+### Matrix bot
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+
+export MATRIX_PLATFORM_BACKEND=real
+export AGENT_BASE_URL=http://127.0.0.1:8000
+export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru
+export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru
+export MATRIX_PASSWORD='YOUR_PASSWORD'
+
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+### Перезапуск Matrix state с нуля
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+rm -f lambda_matrix.db
+rm -rf matrix_store
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+## Smoke test
+
+Рекомендуемый сценарий ручной проверки:
+
+1. Пригласить бота в fresh unencrypted room
+2. Дождаться join
+3. Если это первый invite для данного локального state:
+ - бот создаст private Space
+ - бот создаст room `Чат 1`
+4. Открыть `Чат 1`
+5. Отправить `!start`
+6. Отправить обычное текстовое сообщение
+7. Проверить, что ответ пришёл от live backend, а не от `[MOCK]`
+8. Проверить `!new`
+9. Проверить, что память разделяется между чатами
+
+Если бот уже был однажды провиженен и локальный state не очищался:
+- повторный invite не создаст новую Space-структуру
+- бот просто зайдёт в room и будет отвечать там
+
+Это нормальное поведение текущей реализации.
+
+## Сброс локального Matrix state
+
+Если нужно повторно проверить именно first-invite provisioning:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot
+rm -f lambda_matrix.db
+rm -rf matrix_store
+PYTHONPATH=. uv run python -m adapter.matrix.bot
+```
+
+После этого можно снова приглашать бота как "с нуля".
+
+## Частые проблемы
+
+### 1. `ModuleNotFoundError: lambda_agent_api`
+
+Значит `platform-agent_api` не установлен в `.venv` агента.
+
+Исправление:
+
+```bash
+cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
+uv pip install --python .venv/bin/python -e ../platform-agent_api
+```
+
+### 2. `CERTIFICATE_VERIFY_FAILED` при запуске Matrix bot
+
+Это не ошибка surface logic. Это TLS trust problem до Matrix homeserver.
+
+Нужно:
+- либо установить системные/Python certificates
+- либо передать корпоративный CA через `SSL_CERT_FILE`
+
+### 3. Бот заходит в room, но не создаёт новую Space
+
+Скорее всего user уже есть в локальном state.
+
+Варианты:
+- это ожидаемо для repeat invite
+- либо очистить `lambda_matrix.db` и `matrix_store`
+
+### 4. Бот падает после message send
+
+Значит backend/provider вернул ошибку, которая ещё не была деградирована в user-facing ответ.
+
+Пример уже встречавшегося кейса:
+- неверный model id
+- key не имеет доступа к model
+
+Сначала проверяйте:
+- `PROVIDER_URL`
+- `PROVIDER_MODEL`
+- `PROVIDER_API_KEY`
+
+## Полезные ссылки внутри repo
+
+- [README.md](/Users/a/MAI/sem2/lambda/surfaces-bot/README.md)
+- [adapter/matrix/bot.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/bot.py)
+- [sdk/agent_session.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_session.py)
+- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
+- [sdk/prototype_state.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/prototype_state.py)
+- [2026-04-08-matrix-direct-agent-prototype-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md)
+- [2026-04-08-matrix-direct-agent-prototype.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md)
diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md
index bebf0b4..4d944db 100644
--- a/docs/matrix-prototype.md
+++ b/docs/matrix-prototype.md
@@ -4,263 +4,101 @@
Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space.
-При первом входе бот создаёт для пользователя личное пространство (Space) —
-это как папка в Element. Внутри Space бот создаёт комнату для каждого нового
-чата с агентом. Пользователь видит аккуратную структуру: одно пространство,
-внутри — список чатов. История хранится нативно в Matrix — это часть протокола,
-ничего дополнительно делать не нужно.
+При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату.
+История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms.
-Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики,
-разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные
-команды `!`, локальный state-store и нативные Matrix rooms.
+Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов.
---
-## Аутентификация
+## Онбординг
-### Флоу
-1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате
-2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе
-3. Если нет — бот отправляет одноразовый код или ссылку
-4. Пользователь подтверждает, платформа возвращает токен
-5. Бот сохраняет привязку `matrix_user_id → platform_user_id`
+1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере
+2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1`
+3. Приглашает пользователя в `Чат 1` и пишет приветствие
+4. Дальнейшее общение ведётся в рабочих комнатах, не в DM
-### В моке
-- Любой пользователь проходит аутентификацию автоматически
-- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...»
-- Демонстрирует флоу без реальной платформы
-
----
-
-## Чаты через Space + комнаты (вариант Б)
-
-### Структура
```
Space: «Lambda — {display_name}»
- ├── 💬 Чат 1 ← первый чат, создаётся автоматически
+ ├── 💬 Чат 1 ← создаётся автоматически при invite
├── 💬 Чат 2
- └── 💬 Исследование рынка ← пользователь сам называет
+ └── 💬 Исследование рынка ← пользователь называет сам через !new
```
-### Создание Space
-При первом входе бот:
-1. Создаёт Space `Lambda — {display_name}`
-2. Создаёт первую комнату-чат `Чат 1`
-3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты
-4. Привязывает `chat_id ↔ room_id` в локальном состоянии
-5. Пишет приветствие в `Чат 1`
+**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение).
+
+---
+
+## Работающие команды
### Управление чатами
-Команды работают в зарегистрированных комнатах бота:
| Команда | Действие |
|---|---|
| `!new` | Создать новый чат (новую комнату в Space) |
| `!new Название` | Создать чат с именем |
-| `!help` | Показать шпаргалку по доступным командам |
-| `!rename Название` | Переименовать текущую комнату |
-| `!archive` | Архивировать чат и вывести бота из комнаты |
-| `!chats` | Показать список чатов |
-| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика |
+| `!chats` | Список активных чатов |
+| `!rename <название>` | Переименовать текущую комнату |
+| `!archive` | Архивировать чат |
+| `!help` | Справка |
-### Создание нового чата
-1. Пользователь пишет `!new` или `!new Анализ конкурентов`
-2. Бот создаёт новую комнату в Space
-3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])`
-4. Регистрирует комнату в локальном состоянии и `ChatManager`
-5. Пользователь переходит в новую комнату — начинает диалог
+### Контекст
-### В моке
-- Space и комнаты создаются реально через matrix-nio
-- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
-- История хранится в Matrix нативно
-- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек
+| Команда | Действие |
+|---|---|
+| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) |
+| `!reset` | Псевдоним для `!clear` |
-### Переименование и архивирование
+### Подтверждения
-- `!rename` обновляет имя комнаты через state event `m.room.name`
-- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)`
-- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия
+| Команда | Действие |
+|---|---|
+| `!yes` | Подтвердить действие агента |
+| `!no` | Отменить действие агента |
+
+### Вложения (файловая очередь)
+
+Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу.
+
+| Команда | Действие |
+|---|---|
+| `!list` | Показать файлы в очереди |
+| `!remove ` | Удалить файл из очереди по номеру |
+| `!remove all` | Очистить всю очередь |
+
+Как отправить файлы агенту:
+1. Отправь один или несколько файлов в рабочую комнату
+2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?`
+3. Бот отправит агенту текст вместе со всеми файлами из очереди
---
-## Основной диалог
+## Диалог
-### Флоу сообщения
-1. Пользователь пишет текст в комнату-чат
-2. Бот показывает typing (m.typing event)
-3. Запрос уходит в платформу (MockPlatformClient)
-4. Бот отвечает в той же комнате
-
-### Вложения
-- Файлы, изображения отправляются как Matrix media events
-- Бот принимает `m.file`, `m.image`, `m.audio`
-- Передаёт в платформу как `attachments` через `IncomingMessage`
-- В моке: подтверждение получения + заглушка-ответ
-
-### Реакции как действия
-Matrix поддерживает реакции на сообщения (`m.reaction`).
-Используем это для подтверждения действий агента:
-
-```
-Агент: Хочу отправить письмо на vasya@mail.ru
- Тема: «Отчёт за неделю»
-
- 👍 — подтвердить ❌ — отменить
-```
-
-Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно.
-
-### Треды для длинных задач
-Если агент выполняет долгую задачу (deep research, генерация документа),
-бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда.
-Основной чат не засоряется.
-
-```
-Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде]
- └── Ищу источники... (1/4)
- └── Анализирую статьи... (2/4)
- └── Формирую отчёт... (3/4)
- └── Готово. Отчёт: [...]
-```
+- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор
+- Ответ стримится по WebSocket и выводится в ту же комнату
+- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами
---
-## Настройки и диагностика
+## Передача файлов
-Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные
-`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard
-по скиллам, личности, безопасности и активным чатам.
+### Пользователь → Агент
+Бот сохраняет файл в shared volume: `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}`
+и передаёт агенту относительный путь как `workspace_path`.
-### Коннекторы
-```
-!connectors — показать список
-!connect gmail — подключить Gmail (OAuth ссылка)
-!connect github — подключить GitHub
-!connect calendar — подключить Google Calendar
-!connect notion — подключить Notion
-!disconnect gmail — отключить
-```
-
-Статус:
-```
-Коннекторы:
- ✅ Gmail — подключён (user@gmail.com)
- ❌ GitHub — не подключён → !connect github
- ❌ Google Calendar — не подключён
- ❌ Notion — не подключён
-```
-
-В моке: OAuth ссылка-заглушка → «Подключено ✓»
-
-### Скиллы
-```
-!skills — показать список
-!skill on browser — включить Browser Use
-!skill off browser — выключить
-```
-
-Статус:
-```
-Скиллы:
- ✅ web-search — поиск в интернете
- ✅ fetch-url — чтение веб-страниц
- ✅ email — чтение почты (требует Gmail)
- ❌ browser — управление браузером
- ❌ image-gen — генерация изображений
- ❌ video-gen — генерация видео
- ✅ files — работа с файлами
- ❌ calendar — календарь (требует Google Calendar)
-```
-
-В моке: состояние хранится локально.
-
-### Личность агента
-```
-!soul — показать текущий SOUL.md
-!soul name Лямбда — задать имя агента
-!soul style brief — стиль: brief | friendly | formal
-!soul priority «разбирать почту утром» — приоритетная задача
-!soul reset — сбросить к дефолту
-```
-
-В моке: SOUL.md генерируется и хранится локально, агент обращается по имени.
-
-### Безопасность
-```
-!safety — показать настройки
-!safety on email-send — требовать подтверждение перед отправкой письма
-!safety off calendar-create — не спрашивать для создания событий
-```
-
-Статус:
-```
-Подтверждение требуется для:
- ✅ отправка письма
- ✅ удаление файлов
- ✅ публикация в соцсетях
- ❌ создание события в календаре
- ❌ поиск в интернете
-```
-
-### Подписка
-```
-!plan — показать текущий план
-```
-
-```
-Подписка: Beta (бесплатно)
-Токены этот месяц: 800 / 1000
-━━━━━━━━░░ 80%
-```
-
-Заглушка, реализует другая команда.
-
-### Статус и диагностика
-```
-!status — состояние платформы и чатов
-!whoami — текущий аккаунт платформы
-```
-
-```
-Статус:
- Платформа: ✅ доступна
- Аккаунт: user@lambda.lab
- Активных чатов: 3
-```
+### Агент → Пользователь
+Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...`
+и отправляет пользователю как Matrix file message.
---
-## FSM состояния
+## Известные ограничения
-```
-[Invite] → AuthPending → AuthConfirmed
- ↓
- SpaceSetup → Idle (в комнате Настройки)
- ↓
- [новая комната] → ChatCreated → Idle (в чате)
- ↓
- ReceivingMessage → WaitingResponse → Idle
- ↓
- WaitingReaction (confirm) → [✅/❌] → Idle
- ↓
- LongTask → [тред со статусами] → Done → Idle
-```
-
----
-
-## Стек
-
-- Python 3.11+
-- matrix-nio (async) — Matrix клиент
-- MockPlatformClient → `platform/interface.py`
-- structlog для логирования
-- SQLite / in-memory store для хранения `matrix_user_id → platform_user_id`, состояния скиллов и маппинга `chat_id → room_id`
-
----
-
-## Ограничения текущей версии
-
-- Ручной QA и текущая разработка идут только в незашифрованных комнатах
-- После рестарта бот делает bootstrap sync и стартует с `since`, поэтому старые события не должны переигрываться повторно
-- Если удалить локальную БД/стор, старые Matrix rooms останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга
+| Проблема | Причина |
+|---|---|
+| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте |
+| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` |
+| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) |
+| E2EE комнаты | `python-olm` не собирается на macOS/ARM |
+| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы |
diff --git a/docs/max-surface-guide.md b/docs/max-surface-guide.md
new file mode 100644
index 0000000..15b98f1
--- /dev/null
+++ b/docs/max-surface-guide.md
@@ -0,0 +1,340 @@
+# Руководство по созданию новой поверхности Max
+
+Этот документ описывает, как написать новую поверхность для Max по образцу текущей Matrix-поверхности в ветке `feat/deploy`.
+
+Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси.
+
+---
+
+## 1. Общая архитектура
+
+### 1.1. Что такое поверхность
+
+Поверхность — это тонкий адаптер между конкретной платформой (Max) и общим ядром бота.
+
+В репозитории есть разделение:
+
+- `core/` — общее ядро и бизнес-логика
+- `adapter//` — реализация конкретной поверхности
+- `sdk/real.py` — работа с реальной платформой / агентом
+- `config/` — статическая конфигурация агентов
+- `docs/surface-protocol.md` — общий контракт поверхностей
+
+### 1.2. Как это работает
+
+Поверхность должна:
+
+- принимать нативные события от Max
+- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`)
+- передавать их в `core`
+- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`)
+- преобразовывать ответы обратно в нативные Max-сообщения
+
+Поверхность не должна:
+
+- управлять жизненным циклом агентских контейнеров
+- хранить долгую историю бесед вне `core`/платформы
+- аутентифицировать пользователей сама (если это не часть Max API)
+
+---
+
+## 2. Структура новой поверхности
+
+### 2.1. Основные каталоги
+
+Рекомендуемая структура для Max:
+
+```
+adapter/max/
+ bot.py
+ converter.py
+ agent_registry.py
+ files.py
+ handlers/
+ store.py
+```
+
+### 2.2. Принцип reuse
+
+По примеру Matrix surface, Max surface должен переиспользовать общий `core` и общий `sdk`.
+
+Не дублируйте бизнес-логику, а реализуйте только адаптер:
+
+- `adapter/max/converter.py` — конвертация событий Max ⇄ внутренние структуры
+- `adapter/max/bot.py` — основной runtime, старт Max client, loop, отправка/прием
+- `adapter/max/agent_registry.py` — загрузка `config/max-agents.yaml`
+- `adapter/max/files.py` — хранение входящих/исходящих вложений
+
+---
+
+## 3. Контракт входящих/исходящих событий
+
+### 3.1. Внутренний формат
+
+Смотрите `core/protocol.py`. Основные типы:
+
+- `IncomingMessage` — обычное текстовое сообщение + вложения
+- `IncomingCommand` — управляющая команда
+- `IncomingCallback` — подтверждение / интерактивные действия
+- `OutgoingMessage` — ответ пользователю
+- `OutgoingUI` — интерфейсные элементы (кнопки и т.п.)
+- `OutgoingTyping` — индикатор печати
+- `OutgoingNotification` — системное уведомление
+
+### 3.2. Пример конверсии Matrix
+
+В Matrix-реализации `adapter/matrix/converter.py`:
+
+- текст `!yes` / `!no` превращается в `IncomingCallback` с `action: confirm/cancel`
+- `!list`/`!remove` говорят не агенту, а surface-процессу
+- вложения `m.file`, `m.image`, `m.audio`, `m.video` нормализуются в `Attachment`
+
+Для Max реализуйте аналогичную логику для native команд вашего клиента.
+
+---
+
+## 4. Реестр агентов и маршрутизация
+
+### 4.1. Что хранит реестр
+
+В текущей Matrix реализации есть `config/matrix-agents.yaml` и `adapter/matrix/agent_registry.py`.
+
+Структура:
+
+```yaml
+user_agents:
+ "@user0:matrix.example.org": agent-0
+ "@user1:matrix.example.org": agent-1
+
+agents:
+ - id: agent-0
+ label: "Agent 0"
+ base_url: "http://lambda.coredump.ru:7000/agent_0/"
+ workspace_path: "/agents/0"
+```
+
+### 4.2. Логика выбора агента
+
+- `user_agents` маппит конкретного пользователя на `agent_id`
+- если user_id не найден, используется первый агент из списка
+- `agents[].base_url` определяет URL агента
+- `agents[].workspace_path` определяет путь внутри surface-контейнера для этого агента
+
+Это важно: именно на этом контракте строится разделение агентов по рабочим каталогам.
+
+### 4.3. Рекомендуемая Max-версия
+
+Создайте `config/max-agents.yaml` с тем же смыслом.
+
+- `user_agents` — маппинг Max user_id → agent_id
+- `agents` — список агентов
+- `workspace_path` для каждого агента должен быть абсолютным путем внутри surface-контейнера, например `/agents/0`
+
+---
+
+## 5. Файловый контракт
+
+### 5.1. Shared volume
+
+Текущее Matrix-решение использует shared volume:
+
+- surface монтирует общий том как `/agents`
+- каждый агент видит свою поддиректорию как `/workspace`
+
+Топология:
+
+```
+Bot (/agents) Agent (/workspace = /agents/N/)
+ /agents/0/report.pdf ←──→ /workspace/report.pdf
+```
+
+### 5.2. Правила записи файлов
+
+В `adapter/matrix/files.py` реализовано:
+
+- входящий файл сохраняется прямо в `{workspace_root}/{filename}`
+- возвращается путь `workspace_path` относительный внутри рабочего каталога агента
+- при коллизии имен создаётся `file (1).ext`, `file (2).ext`
+- `Attachment.workspace_path` передаётся агенту
+
+Для исходящих файлов:
+
+- surface читает файл из `workspace_root / workspace_path`
+- загружает его в платформу
+
+### 5.3. Пример поведения
+
+- Пользователь отправляет файл → surface скачивает файл и кладёт его в agent workspace
+- Агент получает `attachments=["report.pdf"]` и работает с относительным `workspace_path`
+- Агент пишет результат в `/workspace/result.txt`
+- surface читает `/agents/{N}/result.txt` и отправляет файл пользователю
+
+---
+
+## 6. Чат-менеджмент и контекст
+
+### 6.1. `platform_chat_id`
+
+Matrix-реализация использует `platform_chat_id` как стабильный идентификатор чата на стороне агента.
+
+- `room_meta.platform_chat_id` определяется и сохраняется в `adapter/matrix/store.py`
+- `reconcile_startup_state()` восстанавливает отсутствующие `platform_chat_id` при рестарте
+- `RoutedPlatformClient` перенаправляет запросы агенту по `agent_id` + `platform_chat_id`
+
+Для Max surface тот же принцип:
+
+- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id`
+- этот `chat_id` используется для вызовов агента
+- если в Max есть несколько комнат/топиков, каждая должна иметь свой `surface_ref`
+
+### 6.2. Команды управления чатами
+
+Matrix поддерживает следующие команды, которые нужно сохранить в Max:
+
+- `!new [название]` — создать новый чат
+- `!chats` — список активных чатов
+- `!rename <название>` — переименовать текущий чат
+- `!archive` — архивировать чат
+- `!clear` / `!reset` — сбросить контекст текущего чата
+- `!yes` / `!no` — подтвердить или отменить действие агента
+- `!list` — показать очередь вложений
+- `!remove ` / `!remove all` — удалить вложение из очереди
+- `!help` — справка
+
+Эти команды реализованы в Matrix через `adapter/matrix/handlers/`.
+
+### 6.3. Очередь вложений
+
+Matrix surface поддерживает staged attachments:
+
+- файл может быть отправлен без текста
+- surface сохраняет файл в `staged_attachments` для конкретного room_id + user_id
+- следующий текст отправляется агенту вместе со всеми файлами из очереди
+
+В Max можно реализовать ту же модель:
+
+- `!list` показывает текущую очередь
+- `!remove` удаляет файл из очереди
+- команда-индикатор или следующее текстовое сообщение отправляет queued attachments агенту
+
+---
+
+## 7. Runtime и окружение
+
+### 7.1. Переменные среды
+
+Для Matrix surface текущий runtime ожидает:
+
+- `MATRIX_HOMESERVER` — URL Matrix-сервера
+- `MATRIX_USER_ID` — `@bot:example.org`
+- `MATRIX_PASSWORD` или `MATRIX_ACCESS_TOKEN`
+- `MATRIX_PLATFORM_BACKEND` — должно быть `real` для продакшна
+- `MATRIX_AGENT_REGISTRY_PATH` — путь к `config/matrix-agents.yaml`
+- `AGENT_BASE_URL` — fallback URL агента
+- `SURFACES_WORKSPACE_DIR` — путь к shared volume внутри контейнера (по умолчанию `/workspace` в коде, но в docs рекомендуют `/agents`)
+
+Для Max surface используйте аналогичные переменные:
+
+- `MAX_PLATFORM_BACKEND=real`
+- `MAX_AGENT_REGISTRY_PATH=/app/config/max-agents.yaml`
+- `SURFACES_WORKSPACE_DIR=/agents`
+- `AGENT_BASE_URL` — если хотите общий fallback
+
+### 7.2. Environment contract
+
+В коде `adapter/matrix/bot.py`:
+
+- `_agent_base_url_from_env()` читает `AGENT_BASE_URL` или `AGENT_WS_URL`
+- `_load_agent_registry_from_env()` читает `MATRIX_AGENT_REGISTRY_PATH`
+- `_build_platform_from_env()` выбирает `RealPlatformClient` при `MATRIX_PLATFORM_BACKEND=real`
+
+В Max surface реализуйте ту же логику, заменив префиксы на `MAX_`.
+
+---
+
+## 8. Тестирование и валидация
+
+### 8.1. Юнит-тесты
+
+В ветке есть покрытие для Matrix surface:
+
+- `tests/adapter/matrix/test_files.py`
+- `tests/adapter/matrix/test_dispatcher.py`
+- `tests/adapter/matrix/test_routed_platform.py`
+- `tests/adapter/matrix/test_reconciliation.py`
+- `tests/adapter/matrix/test_context_commands.py`
+
+Для Max создайте аналогичные тесты:
+
+- проверка загрузки вложений
+- проверка маршрутизации по `agent_id`
+- проверка восстановления `platform_chat_id`
+- проверка конвертации команд
+
+### 8.2. Smoke-проверка deployment
+
+Для Matrix surface есть `docker-compose.prod.yml` и `docker-compose.fullstack.yml`.
+
+Для Max surface должно быть достаточно:
+
+- bot-only production deployment
+- shared volume `/agents`
+- независимая проверка `config/max-agents.yaml`
+- проверка, что surface запускается без локального агента
+
+### 8.3. Проверка контрактов
+
+Особое внимание:
+
+- `agent_registry` должен загружать `workspace_path`
+- file flow должен поддерживать `workspace_path` в `Attachment`
+- отправка файлов должна использовать `resolve_workspace_attachment_path()`
+- `platform_chat_id` должен существовать до вызова агента
+
+---
+
+## 9. Реализация шаг за шагом
+
+1. Скопировать `adapter/matrix/` как шаблон для `adapter/max/`.
+2. Сделать `adapter/max/converter.py`:
+ - превратить native Max-сообщения в `IncomingMessage`
+ - превратить команды в `IncomingCommand`
+ - превратить yes/no-подтверждения в `IncomingCallback`
+3. Сделать `adapter/max/agent_registry.py` на основе `adapter/matrix/agent_registry.py`.
+4. Сделать `adapter/max/files.py` на основе `adapter/matrix/files.py`.
+5. Сделать `adapter/max/bot.py`:
+ - инстанцировать runtime
+ - читать env vars `MAX_*`
+ - загружать реестр агентов
+ - обрабатывать входящие события
+ - отправлять `Outgoing*` обратно в Max
+6. Реализовать команды управления чатами и очередь вложений.
+7. Прописать `config/max-agents.yaml`.
+8. Прописать `docker-compose.max.yml` или аналог, чтобы surface монтировал `/agents`.
+9. Написать тесты по аналогии с `tests/adapter/matrix/`.
+10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных.
+
+---
+
+## 10. Важные замечания
+
+- Текущий Matrix surface на ветке `feat/deploy` — активная реализация, а не устаревший легаси.
+- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе.
+- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`.
+- Для Max surface сохраните ту же архитектуру: surface = thin adapter, агенты = внешние сервисы.
+- Не пытайтесь в surface реализовывать логику запуска/стопа агент-контейнеров.
+
+---
+
+## 11. Полезные ссылки внутри репозитория
+
+- `README.md`
+- `docs/deploy-architecture.md`
+- `docs/surface-protocol.md`
+- `adapter/matrix/bot.py`
+- `adapter/matrix/converter.py`
+- `adapter/matrix/agent_registry.py`
+- `adapter/matrix/files.py`
+- `adapter/matrix/routed_platform.py`
+- `adapter/matrix/reconciliation.py`
+- `tests/adapter/matrix/`
diff --git a/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md b/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
new file mode 100644
index 0000000..f183ede
--- /dev/null
+++ b/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
@@ -0,0 +1,245 @@
+# Баг-репорт: регрессия стриминга платформы после file/tool flow
+
+## Кратко
+
+После обновления до текущих upstream-версий платформы стриминг ответов стал нестабильным в сценариях с вложениями и tool/file flow.
+
+Наблюдаемые симптомы:
+
+- первый текстовый chunk ответа может приходить уже обрезанным
+- соседние ответы могут "протекать" друг в друга
+- после некоторых запросов бот перестаёт присылать финальный ответ
+- платформа присылает дублирующий `END`
+
+До обновления платформы этот класс ошибок у нас не воспроизводился.
+
+## Версии платформы
+
+В рантайме используются upstream-репозитории без локальных правок:
+
+- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
+- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
+
+## Контекст интеграции
+
+- поверхность: Matrix
+- транспорт к платформе: websocket через `platform-agent_api`
+- `chat_id` на платформу отправляется как стабильный числовой surrogate id
+- shared workspace: `/workspace`
+
+Важно: vendored platform repos чистые, совпадают с upstream. Проблема воспроизводится без локальных patch'ей в платформу.
+
+## Пользовательские симптомы
+
+Примеры из живого диалога:
+
+- ожидалось: `Моя ошибка: ...`
+- фактически пришло: `оя ошибка: ...`
+
+- ожидалось начало ответа вида `По фото IMG_3183.png ...`
+- фактически пришло: `IMG_3183.png**) — это ...`
+
+Также наблюдалось:
+
+- после вопросов по изображениям бот иногда вообще перестаёт отвечать
+- в том же чате, до attachment/tool flow, ответы приходят корректно
+
+## Шаги воспроизведения
+
+1. Поднять `platform-agent` и Matrix surface на версиях выше.
+2. Отправить несколько обычных текстовых сообщений.
+3. Убедиться, что начальные ответы стримятся корректно.
+4. Отправить изображения/файлы и задать вопросы вида:
+ - `что изображено на фото`
+ - уточняющие follow-up вопросы по тем же вложениям
+5. Затем отправить ещё одно обычное текстовое сообщение.
+6. Наблюдать один или несколько симптомов:
+ - первый chunk начинается с середины слова
+ - ответ начинается с середины фразы
+ - хвост прошлого ответа загрязняет следующий
+ - видимого финального ответа нет вообще
+
+## Что удалось доказать
+
+По debug-логам Matrix surface видно, что обрезание присутствует уже в первом chunk, полученном от платформы.
+
+Корректные первые chunk'и до attachment/tool flow:
+
+- `Hey! How`
+- `Я`
+- `Первый файл не найден — возможно, ...`
+
+Некорректные первые chunk'и после attachment/tool flow:
+
+- `IMG_3183.png**) — это ю...`
+- `оя ошибка: в первом запросе...`
+
+Это логируется сразу после десериализации websocket event на клиентской стороне, до Matrix rendering и до финальной сборки текста ответа. Из этого следует, что повреждение происходит на стороне платформенного стриминга, а не в Matrix sender.
+
+## Дополнительное наблюдение по протоколу
+
+Платформа сейчас отправляет дублирующий `END`.
+
+Релевантные места в upstream:
+
+- `external/platform-agent/src/agent/service.py`
+ - уже `yield MsgEventEnd(...)`
+- `external/platform-agent/src/api/external.py`
+ - после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
+
+В живых логах это видно как:
+
+- первый `END`
+- второй `END`
+- клиентская suppression логика, которая гасит дубликат
+
+Это само по себе делает границы ответа неоднозначными и повышает риск "протекания" поздних событий в следующий запрос.
+
+## Предполагаемая первопричина
+
+Похоже, что на стороне платформы одновременно есть две проблемы.
+
+### 1. Двойной сигнал завершения стрима
+
+Для одного ответа генерируется два `END`.
+
+Вероятные последствия:
+
+- нечёткая граница ответа
+- поздние события могут относиться не к тому запросу
+- соседние ответы могут смешиваться
+
+### 2. Некорректное извлечение текстового chunk'а
+
+В текущем upstream `platform-agent/src/agent/service.py` текст форвардится из `chunk.content` внутри `on_chat_model_stream`.
+
+Однако в документации LangChain для `astream_events()` примеры используют `event["data"]["chunk"].text`, а в Deep Agents stream дополнительно есть `ns`/`source`, которые важны для отделения main-agent output от tool/subagent/model-internal stream.
+
+Потенциальные последствия:
+
+- первый видимый chunk может быть неполным
+- во внешний клиент может попадать не только финальный пользовательский текст
+- attachment/tool flow сильнее деградирует поведение стрима
+
+## Почему проблема считается платформенной
+
+С нашей стороны были проверены и исключены базовые причины:
+
+- вложения корректно сохраняются в `/workspace`
+- контейнер `platform-agent` видит эти файлы
+- Matrix surface получает уже обрезанный первый chunk от платформы
+- обрезание происходит до сборки финального ответа
+- эксперимент с reconnect на каждый запрос не исправил проблему
+- платформенные vendored repos сейчас совпадают с upstream
+
+## Ожидаемое поведение
+
+Для каждого пользовательского запроса:
+
+- текстовые chunk'и должны начинаться с реального начала ответа модели
+- должен приходить ровно один terminal `END`
+- границы ответов должны быть однозначными
+- file/tool flow не должен ломать следующий ответ
+
+## Фактическое поведение
+
+После attachment/tool flow:
+
+- первый text chunk может быть уже обрезан
+- `END` приходит дважды
+- следующий ответ может начаться с середины слова или фразы
+- отдельные запросы могут не завершаться видимым ответом
+
+## Дополнительный failure mode: большие изображения
+
+В отдельном воспроизведении текстовые запросы до файлового сценария работали корректно, а сбой возникал только после попытки анализа изображений.
+
+По логам видно уже не только stream corruption, но и конкретный image-path failure:
+
+- `platform-agent` рвёт websocket с `1009 (message too big)`
+- провайдер возвращает `400` с причиной:
+ - `Exceeded limit on max bytes per data-uri item : 10485760`
+
+Характерный фрагмент:
+
+```text
+websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big)
+...
+Agent error (INTERNAL_ERROR): Error code: 400 - {
+ 'error': {
+ 'message': 'Provider returned error',
+ 'metadata': {
+ 'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760"...}}'
+ }
+ }
+}
+```
+
+Из этого следует:
+
+- текстовый path сам по себе работоспособен
+- image-analysis path в платформе сейчас передаёт изображение как data URI
+- для достаточно больших изображений это утыкается в provider limit `10,485,760` байт на один data-uri item
+- параллельно websocket path между surface и platform-agent неустойчив к этому failure scenario и закрывается с `1009`
+
+То есть в платформе есть как минимум ещё одна отдельная проблема помимо некорректного стриминга:
+
+- отсутствует безопасная обработка больших изображений до отправки в provider
+- отсутствует аккуратная деградация без разрыва websocket-сессии
+
+## Что стоит исправить в платформе
+
+1. Отправлять ровно один `MsgEventEnd` на один ответ.
+2. Перепроверить extraction текста из `on_chat_model_stream`:
+ - вероятно, должен использоваться `chunk.text`, а не `chunk.content`.
+3. Учитывать `ns`/`source` и форвардить наружу только main assistant output.
+4. Перед отправкой изображений в provider проверять размер payload и не пытаться слать oversized data-uri.
+5. Для больших изображений:
+ - либо делать resize/compression,
+ - либо возвращать контролируемую user-facing ошибку без разрыва websocket.
+6. В идеале добавить более жёсткую request boundary в websocket protocol, чтобы late events не могли прилипать к следующему запросу.
+
+## Наши временные mitigation'ы на стороне surface
+
+Они не исправляют корень, только снижают ущерб:
+
+- suppression duplicate `END`
+- короткий post-`END` drain window
+- idle timeout для зависшего стрима
+- transport failures нормализуются в `PlatformError`, чтобы Matrix bot не падал процессом
+
+Эти меры понадобились только потому, что в текущем сценарии platform stream contract нестабилен.
+
+## Приложение: характерный фрагмент логов
+
+```text
+[matrix-bot] text chunk queue=True text='Первый файл не найден — возможно,'
+[matrix-bot] ...
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] dropped duplicate END tokens=0
+[matrix-bot] text chunk queue=True text='IMG_3183.png**) — это ю'
+[matrix-bot] ...
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] end event queue=True tokens=0
+[matrix-bot] dropped duplicate END tokens=0
+[matrix-bot] text chunk queue=True text='оя ошибка: в первом запросе я случайно постав'
+```
+
+Этот фрагмент показывает две вещи:
+
+- duplicate `END` действительно приходит от платформы
+- следующий первый chunk уже приходит в клиента обрезанным
+
+## Приложение: характерный фрагмент логов для больших изображений
+
+```text
+platform-agent-1 | websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big)
+...
+matrix-bot-1 | Agent error (INTERNAL_ERROR): Error code: 400 - {'error': {'message': 'Provider returned error', 'code': 400, 'metadata': {'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760","type":"invalid_request_error"...}}}}
+```
+
+Этот фрагмент показывает ещё две вещи:
+
+- image path в платформе реально упирается в лимит провайдера на размер data URI
+- при этом ошибка не изолируется корректно и сопровождается разрывом websocket-соединения
diff --git a/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md b/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md
new file mode 100644
index 0000000..d03adc6
--- /dev/null
+++ b/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md
@@ -0,0 +1,294 @@
+# Финальный баг-репорт: потеря начала ответа и сбои streaming/image path в `platform-agent`
+
+## Статус
+
+Это финальный отчёт после полного аудита интеграции `surfaces -> platform-agent_api -> platform-agent`.
+
+Итог:
+
+- текущая реализация `surfaces` рабочая, но проблемная из-за upstream-дефектов платформы
+- баг с пропадающим началом ответа на текущем состоянии **не локализуется в `surfaces`**
+- в воспроизведённом сценарии повреждённый первый текстовый chunk рождается уже внутри `platform-agent`
+- помимо этого подтверждены ещё два независимых platform-side дефекта:
+ - duplicate `END`
+ - некорректная обработка больших изображений (`data-uri > 10 MB`, `WS 1009`)
+
+## Версии и состояние кода
+
+Рантайм воспроизводился на vendored upstream-репозиториях без локальных platform patch’ей:
+
+- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
+- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
+
+Со стороны `surfaces` transport layer был предварительно очищен:
+
+- убрана локальная stream-semantics из `sdk/agent_api_wrapper.py`
+- `sdk/real.py` переведён на pinned upstream `platform-agent_api.AgentApi`
+- больше нет локального post-END drain, custom listener и wrapper-owned reclassification events
+
+Это важно: баг воспроизводился **после** удаления наших транспортных костылей.
+
+## Контекст интеграции
+
+- поверхность: Matrix
+- транспорт к платформе: WebSocket через upstream `platform-agent_api.AgentApi`
+- `chat_id` на платформу: стабильный числовой surrogate id, выдаваемый со стороны `surfaces`
+- файловый контракт: shared `/workspace`, вложения передаются как относительные пути в `attachments`
+
+## Пользовательские симптомы
+
+Наблюдались несколько классов сбоев:
+
+1. Начало ответа может пропасть
+- ожидалось: `Моя ошибка: ...`
+- фактически: `оя ошибка: ...`
+
+- ожидалось: `На двух изображениях: ...`
+- фактически: ` двух изображениях: ...`
+
+2. После tool/file flow ответы могут вести себя нестабильно
+- следующий ответ стартует с середины фразы
+- в некоторых сценариях после image/tool path платформа отвечает ошибкой или замолкает
+
+3. На больших изображениях image path падает совсем
+- provider error `Exceeded limit on max bytes per data-uri item : 10485760`
+- websocket закрывается с `1009 (message too big)`
+
+## Что было проверено на стороне `surfaces`
+
+Ниже перечислено, что именно было перепроверено в нашем коде и почему это не выглядит корнем проблемы.
+
+### 1. Мы больше не режем и не переклассифицируем stream локально
+
+В текущем `surfaces`:
+
+- `sdk/agent_api_wrapper.py` — thin construction/factory shim над upstream `AgentApi`
+- `sdk/real.py` — просто итерирует upstream events и склеивает `MsgEventTextChunk.text`
+- `adapter/matrix/bot.py` — отправляет `OutgoingMessage.text` в Matrix без `strip/lstrip`
+
+Наблюдение:
+
+- в текущем коде не осталось места, где строка могла бы превратиться из `Моя ошибка` в `оя ошибка` через локальный slicing
+
+### 2. Сборка ответа у нас линейная и тупая
+
+`sdk/real.py` делает только следующее:
+
+- если пришёл `MsgEventTextChunk` — добавляет `event.text` в `response_parts`
+- если пришёл `MsgEventSendFile` — превращает его в `Attachment`
+- не пытается “восстанавливать” поток после `END`
+
+Следствие:
+
+- если начало уже отсутствует в `event.text`, мы его не можем ни потерять, ни вернуть
+
+### 3. Matrix sender не модифицирует текст
+
+`adapter/matrix/bot.py` передаёт текст дальше как есть.
+
+Следствие:
+
+- Matrix renderer не является объяснением пропажи первого куска
+
+## Что было проверено в `platform-agent_api`
+
+Upstream client всё ещё имеет спорную queue-архитектуру:
+
+- одна активная `_current_queue`
+- `MsgEventEnd` съедается внутри `send_message()`
+- в `finally` очередь отвязывается и дренится orphan messages
+
+Это архитектурно хрупко и может быть источником других boundary bugs.
+
+Но в конкретном воспроизведении этот слой не был точкой порчи текста.
+
+Почему:
+
+- в raw logs клиент получил **ровно тот же** первый text chunk, который сервер уже отправил
+- queue/dequeue не изменили его содержимое
+
+## Что удалось доказать по raw logs
+
+Для финальной проверки была временно добавлена точечная диагностика в:
+
+- `external/platform-agent/src/agent/service.py`
+- `external/platform-agent/src/api/external.py`
+- `external/platform-agent_api/lambda_agent_api/agent_api.py`
+
+Эта диагностика **не входила** в рабочую реализацию и использовалась только для локализации бага.
+
+### Ключевое наблюдение
+
+На проблемном запросе после tool/file flow сервер сам yield’ил уже обрезанный первый chunk:
+
+```text
+platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text=' двух изображениях:\n\n**Первое изображение'
+platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' path=None
+matrix-bot-1 | [raw-stream][client-listen] agent=matrix-bot chat=1 queue_active=True AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
+matrix-bot-1 | [raw-stream][client-dequeue] agent=matrix-bot chat=1 request=2 AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
+```
+
+Это означает:
+
+- порча произошла **до** websocket-клиента
+- `surfaces` transport layer не является источником именно этого дефекта
+- `platform-agent_api` не исказил этот конкретный chunk по дороге
+
+Дополнительно тот же паттерн виден и вне image-сценария:
+
+```text
+platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text='сё работает напрямую'
+...
+matrix-bot-1 | [raw-stream][client-dequeue] ... text='сё работает напрямую'
+```
+
+То есть сервер уже выдаёт `сё`, а не `Всё`.
+
+## Наиболее вероятный root cause
+
+Главный подозреваемый — `external/platform-agent/src/agent/service.py`.
+
+Сейчас он делает следующее:
+
+- читает `self._agent.astream_events(...)`
+- обрабатывает только `kind == "on_chat_model_stream"`
+- берёт `chunk = event["data"]["chunk"]`
+- если `chunk.content`, форвардит `MsgEventTextChunk(text=chunk.content)`
+
+Проблема в том, что это очень грубое преобразование raw event stream в пользовательский текст.
+
+### Почему именно это место выглядит корнем
+
+1. Первый битый chunk уже рождается на server-side
+- это подтверждено логами выше
+
+2. Код берёт только `chunk.content`
+- если начало ответа приходит в другой форме, поле или raw event, оно просто теряется
+
+3. Код не учитывает `ns` / `source`
+- после tool/vision flow у Deep Agents / LangChain может быть более сложная структура потока
+- текущий adapter flatten’ит её слишком агрессивно
+
+4. Код никак не валидирует, что наружу уходит именно main assistant output
+
+Итоговая гипотеза:
+
+> После tool/file/vision flow `platform-agent` неправильно адаптирует `astream_events()` в `MsgEventTextChunk`. Начало итогового пользовательского ответа может находиться не в том raw event, который сейчас читается через `chunk.content`, либо теряться из-за упрощённой фильтрации потока.
+
+## Подтверждённый отдельный баг: duplicate `END`
+
+Это отдельный platform-side дефект.
+
+Сейчас:
+
+- `external/platform-agent/src/agent/service.py` уже делает `yield MsgEventEnd(...)`
+- `external/platform-agent/src/api/external.py` после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
+
+По логам это выглядит так:
+
+```text
+platform-agent-1 | [raw-stream][server-yield] chat=1 event=END
+platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END text=None path=None
+platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END duplicate_end=true
+matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
+matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
+```
+
+Независимая оценка:
+
+- duplicate `END` — реальный баг платформы
+- он делает границу ответа менее надёжной
+- но в текущем воспроизведении он **не объясняет** сам факт потери первого text chunk
+
+То есть это важный, но вторичный дефект.
+
+## Подтверждённый отдельный баг: большие изображения ломают image path
+
+В отдельном воспроизведении платформа падала на анализе изображений с provider error:
+
+```text
+Exceeded limit on max bytes per data-uri item : 10485760
+```
+
+И параллельно websocket рвался с:
+
+```text
+received 1009 (message too big); then sent 1009 (message too big)
+```
+
+Это означает:
+
+- image path отправляет в provider oversized `data:` URI
+- безопасной предвалидации / деградации нет
+- failure scenario сопровождается разрывом websocket-соединения
+
+Независимая оценка:
+
+- это отдельный platform-side bug
+- он не объясняет потерю первого чанка в текстовом сценарии напрямую
+- но подтверждает, что path `tool/file/image -> platform stream` в целом сейчас нестабилен
+
+## Что мы считаем исключённым
+
+С достаточной уверенностью можно исключить:
+
+1. Локальный slicing текста в `surfaces`
+2. Локальную “умную” реконструкцию потока, потому что она была удалена
+3. Matrix sender как источник потери первого чанка
+4. `platform-agent_api` queue/dequeue как primary root cause именно в этом воспроизведении
+
+## Финальная независимая оценка
+
+Текущая оценка вероятностей:
+
+- `75%` — ошибка в `platform-agent`, в адаптере `astream_events() -> MsgEventTextChunk`
+- `15%` — provider/model stream приносит начало ответа в другой raw event/field, а `platform-agent` его некорректно интерпретирует
+- `10%` — вторичные platform-side boundary defects (`duplicate END`, queue semantics и т.д.)
+- `~0-5%` — ошибка в `surfaces`
+
+Итоговый вывод:
+
+> На текущем состоянии кода баг с пропадающим началом ответа следует считать platform-side дефектом. Воспроизведение после cleanup transport layer показывает, что первый повреждённый text chunk формируется уже внутри `platform-agent` до отправки в websocket.
+
+## Что нужно исправить в платформе
+
+### Обязательно
+
+1. Убрать duplicate `END`
+- один ответ должен завершаться ровно одним `MsgEventEnd`
+
+2. Перепроверить адаптацию `astream_events()` в `service.py`
+- логировать и проанализировать raw `event["event"]`
+- проверить `event.get("name")`
+- смотреть `event.get("ns")`
+- сравнить `chunk.content` с тем, что реально лежит в `chunk.text` / raw chunk repr
+
+3. Форвардить наружу только финальный main assistant output
+- не flatten’ить весь поток без учёта `ns/source`
+
+### Желательно
+
+4. Сделать image path устойчивым к oversized payload
+- preflight check размера
+- resize/compress или controlled error без разрыва WS
+
+5. Улучшить client/server protocol boundary
+- более строгая корреляция запроса и ответа
+- более однозначная semantics конца ответа
+
+## Что мы сделали со своей стороны
+
+Со стороны `surfaces` уже выполнено следующее:
+
+- transport layer очищен до thin adapter над upstream `AgentApi`
+- локальные stream-workaround’ы удалены
+- рабочая интеграция сохранена
+- known issue задокументирован
+
+То есть текущая интеграция не “идеальна”, но её поведение теперь достаточно чистое, чтобы platform bug было можно локализовать без смешения ответственности.
+
+## Приложение: короткий диагноз
+
+Если нужна самая короткая формулировка для issue tracker:
+
+> После cleanup transport layer в `surfaces` и воспроизведения на clean upstream platform repos видно, что `platform-agent` иногда сам порождает первый `MsgEventTextChunk` уже обрезанным, особенно после tool/file flow. Дополнительно подтверждены duplicate `END` и отдельный image-path failure на больших `data:` URI.
diff --git a/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md b/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md
new file mode 100644
index 0000000..e9a9921
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md
@@ -0,0 +1,515 @@
+# Matrix Direct-Agent Prototype Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace `MockPlatformClient` with a real Matrix-only direct-agent prototype backend while keeping Matrix adapter logic stable and preserving a clean future migration path.
+
+**Architecture:** Introduce a thin compatibility layer under `sdk/` that splits direct WebSocket agent messaging from local prototype-only control-plane state. Keep `core/` and Matrix handlers on the existing `PlatformClient` contract, and limit surface changes to runtime wiring and tests.
+
+**Tech Stack:** Python 3.11, `aiohttp` WebSocket client, `matrix-nio`, `pydantic`, `pytest`, `pytest-asyncio`
+
+---
+
+## File Structure
+
+- Create: `sdk/agent_session.py`
+ Purpose: Minimal direct WebSocket client for the real agent, including thread-key derivation, request/response parsing, and sync/stream helpers.
+
+- Create: `sdk/prototype_state.py`
+ Purpose: Local prototype-only user mapping and settings store kept behind a small API.
+
+- Create: `sdk/real.py`
+ Purpose: `PlatformClient` implementation that composes `AgentSessionClient` and `PrototypeStateStore`.
+
+- Modify: `sdk/__init__.py`
+ Purpose: export `RealPlatformClient` if useful for runtime imports.
+
+- Modify: `adapter/matrix/bot.py`
+ Purpose: runtime/backend selection and env-based configuration for mock vs real backend.
+
+- Create: `tests/platform/test_agent_session.py`
+ Purpose: transport-level tests for direct agent communication.
+
+- Create: `tests/platform/test_prototype_state.py`
+ Purpose: unit tests for local user/settings behavior.
+
+- Create: `tests/platform/test_real.py`
+ Purpose: contract tests for `RealPlatformClient`.
+
+- Modify: `tests/core/test_integration.py`
+ Purpose: prove the new platform implementation preserves core behavior.
+
+- Modify: `README.md`
+ Purpose: document backend selection and prototype limitations after code is working.
+
+---
+
+### Task 1: Add Direct Agent Session Transport
+
+**Files:**
+- Create: `sdk/agent_session.py`
+- Test: `tests/platform/test_agent_session.py`
+
+- [ ] **Step 1: Write the failing transport tests**
+
+```python
+import pytest
+
+from sdk.agent_session import AgentSessionClient, build_thread_key
+
+
+def test_build_thread_key_uses_surface_user_and_chat_id():
+ assert build_thread_key("matrix", "@alice:example.org", "C1") == "matrix:@alice:example.org:C1"
+
+
+@pytest.mark.asyncio
+async def test_send_message_collects_text_chunks_and_tokens(aiohttp_server):
+ ...
+
+
+@pytest.mark.asyncio
+async def test_stream_message_yields_incremental_chunks(aiohttp_server):
+ ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/platform/test_agent_session.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.agent_session'`
+
+- [ ] **Step 3: Write minimal transport implementation**
+
+```python
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import AsyncIterator
+
+import aiohttp
+
+from sdk.interface import MessageChunk, MessageResponse, PlatformError
+
+
+def build_thread_key(platform: str, user_id: str, chat_id: str) -> str:
+ return f"{platform}:{user_id}:{chat_id}"
+
+
+@dataclass
+class AgentSessionConfig:
+ base_ws_url: str
+ timeout_seconds: float = 30.0
+
+
+class AgentSessionClient:
+ def __init__(self, config: AgentSessionConfig) -> None:
+ self._config = config
+
+ async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
+ chunks = []
+ tokens_used = 0
+ async for chunk in self.stream_message(thread_key=thread_key, text=text):
+ chunks.append(chunk.delta)
+ tokens_used = chunk.tokens_used or tokens_used
+ return MessageResponse(
+ message_id=thread_key,
+ response="".join(chunks),
+ tokens_used=tokens_used,
+ finished=True,
+ )
+
+ async def stream_message(self, *, thread_key: str, text: str) -> AsyncIterator[MessageChunk]:
+ url = f"{self._config.base_ws_url}?thread_id={thread_key}"
+ async with aiohttp.ClientSession() as session:
+ async with session.ws_connect(url, heartbeat=30) as ws:
+ status_msg = await ws.receive_json(timeout=self._config.timeout_seconds)
+ if status_msg.get("type") != "STATUS":
+ raise PlatformError("Agent did not send STATUS", code="AGENT_PROTOCOL_ERROR")
+
+ await ws.send_json({"type": "USER_MESSAGE", "text": text})
+
+ while True:
+ payload = await ws.receive_json(timeout=self._config.timeout_seconds)
+ msg_type = payload.get("type")
+ if msg_type == "AGENT_EVENT_TEXT_CHUNK":
+ yield MessageChunk(message_id=thread_key, delta=payload["text"], finished=False)
+ elif msg_type == "AGENT_EVENT_END":
+ yield MessageChunk(
+ message_id=thread_key,
+ delta="",
+ finished=True,
+ tokens_used=payload.get("tokens_used", 0),
+ )
+ return
+ elif msg_type == "ERROR":
+ raise PlatformError(payload.get("details", "Agent error"), code=payload.get("code", "AGENT_ERROR"))
+ else:
+ raise PlatformError(f"Unexpected agent message: {payload}", code="AGENT_PROTOCOL_ERROR")
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pytest tests/platform/test_agent_session.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_session.py tests/platform/test_agent_session.py
+git commit -m "feat: add direct agent session transport"
+```
+
+---
+
+### Task 2: Add Local Prototype State For Users And Settings
+
+**Files:**
+- Create: `sdk/prototype_state.py`
+- Test: `tests/platform/test_prototype_state.py`
+
+- [ ] **Step 1: Write the failing state tests**
+
+```python
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.prototype_state import PrototypeStateStore
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_user_is_stable_per_surface_identity():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_settings_defaults_match_existing_mock_shape():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_update_settings_supports_toggle_skill_and_setters():
+ ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/platform/test_prototype_state.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.prototype_state'`
+
+- [ ] **Step 3: Write minimal state implementation**
+
+```python
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sdk.interface import User, UserSettings
+
+# Defaults are defined here, not imported from sdk.mock, to keep real backend
+# isolated from the mock. Copy-paste intentional.
+DEFAULT_SKILLS: dict[str, bool] = {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+}
+DEFAULT_SAFETY: dict[str, bool] = {"email-send": True, "file-delete": True, "social-post": True}
+DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""}
+DEFAULT_PLAN: dict = {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000}
+
+
+class PrototypeStateStore:
+ def __init__(self) -> None:
+ self._users: dict[str, User] = {}
+ self._settings: dict[str, dict] = {}
+
+ async def get_or_create_user(
+ self,
+ *,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ key = f"{platform}:{external_id}"
+ existing = self._users.get(key)
+ if existing is not None:
+ return existing.model_copy(update={"is_new": False})
+
+ user = User(
+ user_id=f"usr-{platform}-{external_id}",
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ created_at=datetime.now(UTC),
+ is_new=True,
+ )
+ self._users[key] = user.model_copy(update={"is_new": False})
+ return user
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ stored = self._settings.get(user_id, {})
+ return UserSettings(
+ skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
+ connectors=stored.get("connectors", {}),
+ soul={**DEFAULT_SOUL, **stored.get("soul", {})},
+ safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
+ plan={**DEFAULT_PLAN, **stored.get("plan", {})},
+ )
+
+ async def update_settings(self, user_id: str, action) -> None:
+ settings = self._settings.setdefault(user_id, {})
+ if action.action == "toggle_skill":
+ skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
+ skills[action.payload["skill"]] = action.payload.get("enabled", True)
+ elif action.action == "set_soul":
+ soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
+ soul[action.payload["field"]] = action.payload["value"]
+ elif action.action == "set_safety":
+ safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
+ safety[action.payload["trigger"]] = action.payload.get("enabled", True)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pytest tests/platform/test_prototype_state.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/prototype_state.py tests/platform/test_prototype_state.py
+git commit -m "feat: add prototype local state store"
+```
+
+---
+
+### Task 3: Implement RealPlatformClient Compatibility Layer
+
+**Files:**
+- Create: `sdk/real.py`
+- Modify: `sdk/__init__.py`
+- Test: `tests/platform/test_real.py`
+- Test: `tests/core/test_integration.py`
+
+- [ ] **Step 1: Write the failing compatibility tests**
+
+```python
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.real import RealPlatformClient
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_get_or_create_user_uses_local_state():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_uses_thread_key():
+ ...
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_settings_are_local():
+ ...
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/platform/test_real.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.real'`
+
+- [ ] **Step 3: Write minimal compatibility wrapper**
+
+```python
+from __future__ import annotations
+
+from typing import AsyncIterator
+
+from sdk.agent_session import AgentSessionClient, build_thread_key
+from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_sessions: AgentSessionClient,
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+ self._agent_sessions = agent_sessions
+ self._prototype_state = prototype_state
+ self._platform = platform # surface name used in thread key; pass explicitly for future surfaces
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ return await self._prototype_state.get_or_create_user(
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ # user_id here is the internal id (e.g. "usr-matrix-@alice:example.org"), which is
+ # unique per user and stable — acceptable as thread identity for v1 prototype.
+ thread_key = build_thread_key(self._platform, user_id, chat_id)
+ return await self._agent_sessions.send_message(thread_key=thread_key, text=text)
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ thread_key = build_thread_key(self._platform, user_id, chat_id)
+ async for chunk in self._agent_sessions.stream_message(thread_key=thread_key, text=text):
+ yield chunk
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ return await self._prototype_state.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action) -> None:
+ await self._prototype_state.update_settings(user_id, action)
+```
+
+- [ ] **Step 4: Run tests to verify the contract holds**
+
+Run: `pytest tests/platform/test_real.py tests/core/test_integration.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/real.py sdk/__init__.py tests/platform/test_real.py tests/core/test_integration.py
+git commit -m "feat: add real platform compatibility layer"
+```
+
+---
+
+### Task 4: Wire Matrix Runtime To Real Backend And Document Usage
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `README.md`
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing runtime wiring tests**
+
+```python
+import os
+
+from adapter.matrix.bot import build_runtime
+from sdk.real import RealPlatformClient
+
+
+def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
+ runtime = build_runtime()
+ assert isinstance(runtime.platform, RealPlatformClient)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because runtime still always constructs `MockPlatformClient`
+
+- [ ] **Step 3: Implement backend selection and docs**
+
+```python
+# adapter/matrix/bot.py — add these imports at the top
+from sdk.agent_session import AgentSessionClient, AgentSessionConfig
+from sdk.interface import PlatformClient
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+
+
+def _build_platform_from_env() -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock")
+ if backend == "real":
+ ws_url = os.environ["AGENT_WS_URL"]
+ return RealPlatformClient(
+ agent_sessions=AgentSessionClient(AgentSessionConfig(base_ws_url=ws_url)),
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ return MockPlatformClient()
+
+
+# Update build_runtime to use env-based selection when no platform is injected:
+def build_runtime(
+ platform: PlatformClient | None = None, # was MockPlatformClient | None
+ store: StateStore | None = None,
+ client: AsyncClient | None = None,
+) -> MatrixRuntime:
+ platform = platform or _build_platform_from_env()
+ ... # rest unchanged
+```
+
+Also update the `MatrixRuntime` dataclass field type from `MockPlatformClient` to `PlatformClient` so the type annotation matches the runtime behavior.
+
+```markdown
+# README.md
+
+Matrix prototype backend selection:
+
+- `MATRIX_PLATFORM_BACKEND=mock` uses `sdk/mock.py`
+- `MATRIX_PLATFORM_BACKEND=real` uses direct agent WebSocket integration
+- `AGENT_WS_URL=ws://host:port/agent_ws/` is required for the real backend
+
+Current real-backend limitations:
+- text chat only
+- local settings storage
+- no attachments or async task callbacks yet
+```
+
+- [ ] **Step 4: Run targeted verification**
+
+Run: `pytest tests/adapter/matrix/test_dispatcher.py tests/platform/test_agent_session.py tests/platform/test_prototype_state.py tests/platform/test_real.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: wire matrix runtime to real backend"
+```
+
+---
+
+## Self-Review
+
+- Spec coverage:
+ - direct-agent transport: Task 1
+ - local settings/user state: Task 2
+ - stable `PlatformClient` wrapper: Task 3
+ - Matrix runtime wiring and docs: Task 4
+- Placeholder scan: no `TBD`, `TODO`, or “implement later” steps remain in the plan.
+- Type consistency:
+ - `build_thread_key(platform, user_id, chat_id)` is used consistently.
+ - `RealPlatformClient` remains the only bot-facing implementation.
+ - local settings stay in `PrototypeStateStore`.
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
+
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
+
+**Which approach?**
diff --git a/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md b/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
new file mode 100644
index 0000000..ed4b80e
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
@@ -0,0 +1,480 @@
+# Matrix Per-Chat Context Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Move the Matrix surface from a shared agent context to true per-room platform contexts mapped through `platform_chat_id`, including `!new`, `!branch`, lazy migration, and per-room context commands.
+
+**Architecture:** Matrix keeps owning UX chats (`C1`, `C2`, rooms, spaces). Each working room stores a `platform_chat_id` in `room_meta`, and platform-facing operations use that mapping. Legacy rooms without a mapping lazily create one on first context-aware use.
+
+**Tech Stack:** Python 3.11, Matrix nio adapter, local state store, `lambda_agent_api`, pytest
+
+---
+
+### Task 1: Add `platform_chat_id` to Matrix metadata and tests
+
+**Files:**
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_store.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore):
+ meta = {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "chat-platform-1",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ saved = await get_room_meta(store, "!r:m.org")
+ assert saved is not None
+ assert saved["platform_chat_id"] == "chat-platform-1"
+```
+
+- [ ] **Step 2: Run test to verify it fails or proves missing coverage**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+Expected: either FAIL on missing assertion coverage or PASS after adding the new test with current generic storage behavior
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/store.py
+# No schema gate is required because room metadata is already stored as a dict.
+# Keep helpers unchanged, but add focused helper functions if they reduce repeated logic:
+
+async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None:
+ meta = await get_room_meta(store, room_id)
+ return meta.get("platform_chat_id") if meta else None
+
+
+async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None:
+ meta = await get_room_meta(store, room_id) or {}
+ meta["platform_chat_id"] = platform_chat_id
+ await set_room_meta(store, room_id, meta)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
+git commit -m "feat: add platform chat id room metadata helpers"
+```
+
+### Task 2: Extend the platform wrapper to support context-aware API calls
+
+**Files:**
+- Modify: `sdk/agent_api_wrapper.py`
+- Modify: `sdk/real.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+@pytest.mark.asyncio
+async def test_real_client_send_message_uses_platform_chat_id():
+ api = FakeAgentApi()
+ client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore())
+
+ await client.send_message("@alice:example.org", "chat-platform-1", "hello")
+
+ assert api.sent == [("chat-platform-1", "hello")]
+
+
+@pytest.mark.asyncio
+async def test_real_client_create_and_branch_context_delegate_to_agent_api():
+ api = FakeAgentApi(create_ids=["chat-new", "chat-branch"])
+ client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore())
+
+ created = await client.create_chat_context("@alice:example.org")
+ branched = await client.branch_chat_context("@alice:example.org", "chat-source")
+
+ assert created == "chat-new"
+ assert branched == "chat-branch"
+ assert api.branch_calls == ["chat-source"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+Expected: FAIL because `RealPlatformClient` does not yet expose context-aware methods or pass a platform context id through
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# sdk/agent_api_wrapper.py
+class AgentApiWrapper(AgentApi):
+ async def create_chat(self) -> str:
+ ...
+
+ async def branch_chat(self, chat_id: str) -> str:
+ ...
+
+ async def send_message(self, chat_id: str, text: str):
+ ...
+
+ async def save_context(self, chat_id: str, name: str) -> None:
+ ...
+
+ async def load_context(self, chat_id: str, name: str) -> None:
+ ...
+
+
+# sdk/real.py
+class RealPlatformClient(PlatformClient):
+ async def create_chat_context(self, user_id: str) -> str:
+ return await self._agent_api.create_chat()
+
+ async def branch_chat_context(self, user_id: str, from_chat_id: str) -> str:
+ return await self._agent_api.branch_chat(from_chat_id)
+
+ async def save_chat_context(self, user_id: str, chat_id: str, name: str) -> None:
+ await self._agent_api.save_context(chat_id, name)
+
+ async def load_chat_context(self, user_id: str, chat_id: str, name: str) -> None:
+ await self._agent_api.load_context(chat_id, name)
+
+ async def stream_message(...):
+ async for event in self._agent_api.send_message(chat_id, text):
+ ...
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py
+git commit -m "feat: add context-aware real platform client methods"
+```
+
+### Task 3: Create Matrix-side resolver for mapped or lazy-created platform contexts
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+async def test_existing_room_without_platform_chat_id_gets_lazy_mapping_on_message():
+ runtime = build_runtime(platform=FakeRealPlatformClient(create_ids=["chat-platform-1"]))
+ await set_room_meta(runtime.store, "!room: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)
+ room = SimpleNamespace(room_id="!room:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="hello")
+
+ await bot.on_room_message(room, event)
+
+ meta = await get_room_meta(runtime.store, "!room:example.org")
+ assert meta["platform_chat_id"] == "chat-platform-1"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because no lazy mapping exists
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/bot.py
+async def _ensure_platform_chat_id(self, room_id: str, user_id: str) -> str:
+ meta = await get_room_meta(self.runtime.store, room_id)
+ if meta is None:
+ raise ValueError("room metadata is required")
+ platform_chat_id = meta.get("platform_chat_id")
+ if platform_chat_id:
+ return platform_chat_id
+ if not hasattr(self.runtime.platform, "create_chat_context"):
+ raise ValueError("real platform backend required")
+ platform_chat_id = await self.runtime.platform.create_chat_context(user_id)
+ meta["platform_chat_id"] = platform_chat_id
+ await set_room_meta(self.runtime.store, room_id, meta)
+ return platform_chat_id
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: lazily assign platform chat ids to matrix rooms"
+```
+
+### Task 4: Make `!new` and workspace bootstrap create independent platform contexts
+
+**Files:**
+- Modify: `adapter/matrix/handlers/chat.py`
+- Modify: `adapter/matrix/handlers/auth.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_chat_space.py`
+- Test: `tests/adapter/matrix/test_invite_space.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+async def test_new_chat_assigns_new_platform_chat_id():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ platform = FakeRealPlatformClient(create_ids=["chat-platform-7"])
+ runtime = build_runtime(platform=platform, client=client)
+ await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="new", args=["Research"])
+ )
+
+ meta = await get_room_meta(runtime.store, "!r2:example")
+ assert meta["platform_chat_id"] == "chat-platform-7"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because new chats do not yet store a platform context id
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/handlers/chat.py
+# adapter/matrix/handlers/auth.py
+platform_chat_id = None
+if hasattr(platform, "create_chat_context"):
+ platform_chat_id = await platform.create_chat_context(event.user_id)
+
+await set_room_meta(store, room_id, {
+ "chat_id": chat_id,
+ "matrix_user_id": event.user_id,
+ "space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+})
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: assign platform contexts when creating matrix chats"
+```
+
+### Task 5: Make per-room save, load, and context use the mapped platform context
+
+**Files:**
+- Modify: `adapter/matrix/handlers/context_commands.py`
+- Modify: `adapter/matrix/bot.py`
+- Modify: `sdk/prototype_state.py`
+- Test: `tests/adapter/matrix/test_context_commands.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+@pytest.mark.asyncio
+async def test_save_command_uses_room_platform_chat_id():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await set_room_meta(runtime.store, "!room:example.org", {
+ "chat_id": "C1",
+ "matrix_user_id": "u1",
+ "platform_chat_id": "chat-platform-1",
+ })
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="save", args=["session-a"])
+
+ result = await make_handle_save(...)(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
+
+ assert platform.saved_calls == [("chat-platform-1", "session-a")]
+
+
+@pytest.mark.asyncio
+async def test_context_command_reports_current_room_platform_chat_id():
+ ...
+ assert "chat-platform-1" in result[0].text
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q`
+Expected: FAIL because save/load/context do not currently use room-level platform mappings
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/handlers/context_commands.py
+room_id = await _resolve_room_id(event, chat_mgr)
+meta = await get_room_meta(store, room_id)
+platform_chat_id = meta.get("platform_chat_id")
+
+await platform.save_chat_context(event.user_id, platform_chat_id, name)
+await platform.load_chat_context(event.user_id, platform_chat_id, name)
+
+# sdk/prototype_state.py
+# store current loaded session per user+platform_chat_id instead of only per user when needed for Matrix `!context`
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/context_commands.py adapter/matrix/bot.py sdk/prototype_state.py tests/adapter/matrix/test_context_commands.py
+git commit -m "feat: bind matrix context commands to platform chat ids"
+```
+
+### Task 6: Add `!branch` and help-text updates
+
+**Files:**
+- Modify: `adapter/matrix/handlers/chat.py`
+- Modify: `adapter/matrix/handlers/__init__.py`
+- Modify: `adapter/matrix/handlers/settings.py`
+- Modify: `adapter/matrix/handlers/auth.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_chat_space.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+async def test_branch_creates_new_room_with_branched_platform_chat_id():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r3:example")),
+ room_put_state=AsyncMock(),
+ room_invite=AsyncMock(),
+ )
+ platform = FakeRealPlatformClient(branch_ids=["chat-platform-branch"])
+ runtime = build_runtime(platform=platform, client=client)
+ await set_room_meta(runtime.store, "!current:example", {
+ "chat_id": "C2",
+ "matrix_user_id": "u1",
+ "space_id": "!space:example",
+ "platform_chat_id": "chat-platform-source",
+ })
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="branch", args=["Fork"])
+ )
+
+ meta = await get_room_meta(runtime.store, "!r3:example")
+ assert meta["platform_chat_id"] == "chat-platform-branch"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: FAIL because `branch` is not implemented
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/handlers/chat.py
+def make_handle_branch(client, store):
+ async def handle_branch(event, auth_mgr, platform, chat_mgr, settings_mgr):
+ source_room_id = ...
+ source_meta = await get_room_meta(store, source_room_id)
+ platform_chat_id = await platform.branch_chat_context(event.user_id, source_meta["platform_chat_id"])
+ ...
+ await set_room_meta(store, new_room_id, {
+ "chat_id": new_chat_id,
+ "matrix_user_id": event.user_id,
+ "space_id": space_id,
+ "platform_chat_id": platform_chat_id,
+ })
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: add matrix branch command for platform contexts"
+```
+
+### Task 7: Verify the full Matrix flow and clean up legacy assumptions
+
+**Files:**
+- Modify: `tests/platform/test_real.py`
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+- Modify: `tests/adapter/matrix/test_context_commands.py`
+- Modify: `tests/core/test_integration.py`
+
+- [ ] **Step 1: Add integration coverage for independent room contexts**
+
+```python
+@pytest.mark.asyncio
+async def test_two_rooms_send_messages_into_different_platform_contexts():
+ platform = FakeRealPlatformClient()
+ runtime = build_runtime(platform=platform)
+ await set_room_meta(runtime.store, "!r1:example", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "chat-1"})
+ await set_room_meta(runtime.store, "!r2:example", {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "chat-2"})
+ ...
+ assert platform.sent == [("chat-1", "hello"), ("chat-2", "world")]
+```
+
+- [ ] **Step 2: Run the focused verification suite**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py -q`
+Expected: PASS
+
+- [ ] **Step 3: Run the full Matrix suite**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix -q`
+Expected: PASS
+
+- [ ] **Step 4: Inspect help text and command visibility**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+Expected: PASS with `!branch` present in help and hidden commands still absent
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py
+git commit -m "test: verify matrix per-chat platform context flow"
+```
+
+## Self-Review
+
+- Spec coverage:
+ - `surface_chat -> platform_chat_id` mapping is covered by Tasks 1, 3, and 4.
+ - `!new` independent contexts are covered by Task 4.
+ - `!branch` snapshot flow is covered by Task 6.
+ - per-room `!save`, `!load`, and `!context` are covered by Task 5.
+ - lazy migration for legacy rooms is covered by Task 3.
+ - verification across rooms is covered by Task 7.
+- Placeholder scan:
+ - No `TODO` or `TBD` placeholders remain.
+ - Commands and file paths are concrete.
+- Type consistency:
+ - The plan consistently uses `platform_chat_id` for stored mapping and `create_chat_context` / `branch_chat_context` / `save_chat_context` / `load_chat_context` for platform-facing methods.
diff --git a/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md b/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md
new file mode 100644
index 0000000..65c2018
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md
@@ -0,0 +1,624 @@
+# Matrix Shared Workspace File Flow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Move the Matrix surface to a prod-like shared-workspace file flow where incoming files are downloaded into `/workspace`, passed to `platform-agent` as relative attachment paths, and outbound `send_file` events are delivered back to the Matrix room.
+
+**Architecture:** Keep the platform contract path-based and surface-agnostic. Extend the surface attachment model with a workspace-relative path, update the real SDK bridge to forward attachments and modern agent events, then add a Matrix-specific storage helper plus a shared-volume runtime. This preserves room/context semantics while aligning file flow with upstream `platform-agent`.
+
+**Tech Stack:** Python 3.11, matrix-nio, aiohttp, Docker Compose, pytest, pytest-asyncio
+
+---
+
+## File Structure
+
+- Modify: `core/protocol.py`
+ Purpose: add a workspace-relative attachment field that future surfaces can also use.
+- Modify: `sdk/interface.py`
+ Purpose: keep the platform-side attachment shape aligned with the surface model.
+- Modify: `core/handlers/message.py`
+ Purpose: stop dropping attachments before platform dispatch.
+- Modify: `sdk/agent_api_wrapper.py`
+ Purpose: accept modern upstream agent events and modern WS route semantics.
+- Modify: `sdk/real.py`
+ Purpose: convert attachment objects into workspace-relative paths and forward them to the agent API.
+- Create: `adapter/matrix/files.py`
+ Purpose: Matrix-specific download/upload helper for shared `/workspace`.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: persist incoming Matrix files into shared workspace and render outbound file events back to Matrix.
+- Modify: `tests/core/test_integration.py`
+ Purpose: prove message dispatch keeps attachments and platform send path receives them.
+- Modify: `tests/platform/test_real.py`
+ Purpose: verify attachment forwarding and outbound file events.
+- Create: `tests/adapter/matrix/test_files.py`
+ Purpose: unit coverage for Matrix workspace path building, download handling, and outbound upload behavior.
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+ Purpose: verify Matrix bot file receive/send integration.
+- Modify: `docker-compose.yml`
+ Purpose: define shared `/workspace` runtime between `matrix-bot` and `platform-agent`.
+- Modify: `README.md`
+ Purpose: document the new default runtime and file flow.
+- Modify: `.env.example`
+ Purpose: update real-backend defaults to in-compose service URLs and workspace-aware runtime.
+
+### Task 1: Preserve Attachment Metadata Through Core Message Dispatch
+
+**Files:**
+- Modify: `core/protocol.py`
+- Modify: `sdk/interface.py`
+- Modify: `core/handlers/message.py`
+- Test: `tests/core/test_dispatcher.py`
+- Test: `tests/core/test_integration.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/core/test_integration.py
+class RecordingAgentApi:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, list[str]]] = []
+ self.last_tokens_used = 0
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments or []))
+ yield type("Chunk", (), {"text": f"[REAL] {text}"})()
+ self.last_tokens_used = 5
+
+
+async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
+ dispatcher, agent_api = real_dispatcher
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
+ await dispatcher.dispatch(start)
+
+ msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="Посмотри файл",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ workspace_path="surfaces/matrix/u1/room/inbox/report.pdf",
+ )
+ ],
+ )
+ await dispatcher.dispatch(msg)
+
+ assert agent_api.calls == [
+ ("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])
+ ]
+```
+
+```python
+# tests/core/test_dispatcher.py
+async def test_dispatch_routes_document_before_catchall(dispatcher):
+ async def doc_handler(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="document")]
+
+ async def catch_all(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="text")]
+
+ dispatcher.register(IncomingMessage, "document", doc_handler)
+ dispatcher.register(IncomingMessage, "*", catch_all)
+
+ doc_msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="",
+ attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.txt")],
+ )
+
+ assert (await dispatcher.dispatch(doc_msg))[0].text == "document"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
+
+Expected:
+- FAIL because `Attachment` has no `workspace_path`
+- FAIL because `handle_message(...)` still sends `attachments=[]`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# core/protocol.py
+@dataclass
+class Attachment:
+ type: str
+ url: str | None = None
+ content: bytes | None = None
+ filename: str | None = None
+ mime_type: str | None = None
+ workspace_path: str | None = None
+```
+
+```python
+# sdk/interface.py
+class Attachment(BaseModel):
+ url: str | None = None
+ mime_type: str | None = None
+ size: int | None = None
+ filename: str | None = None
+ workspace_path: str | None = None
+```
+
+```python
+# core/handlers/message.py
+response = await platform.send_message(
+ user_id=event.user_id,
+ chat_id=event.chat_id,
+ text=event.text,
+ attachments=event.attachments,
+)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/protocol.py sdk/interface.py core/handlers/message.py tests/core/test_dispatcher.py tests/core/test_integration.py
+git commit -m "feat: preserve workspace attachments through message dispatch"
+```
+
+### Task 2: Upgrade the Real SDK Bridge to Modern Agent File Events
+
+**Files:**
+- Modify: `sdk/agent_api_wrapper.py`
+- Modify: `sdk/real.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/platform/test_real.py
+class FakeSendFileEvent:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+
+class FakeChatAgentApi:
+ ...
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments or []))
+ midpoint = len(text) // 2
+ yield FakeChunk(text[:midpoint])
+ yield FakeChunk(text[midpoint:])
+ self.last_tokens_used = 3
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_forwards_workspace_paths():
+ agent_api = FakeAgentApiFactory()
+ client = RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ await client.send_message(
+ "@alice:example.org",
+ "chat-7",
+ "hello",
+ attachments=[
+ type("Attachment", (), {"workspace_path": "surfaces/matrix/alice/room/file.pdf"})()
+ ],
+ )
+
+ assert agent_api.instances["chat-7"].calls == [
+ ("hello", ["surfaces/matrix/alice/room/file.pdf"])
+ ]
+
+
+def test_agent_api_wrapper_treats_send_file_as_known_event(monkeypatch):
+ seen = []
+
+ class FakeSendFile:
+ type = "AGENT_EVENT_SEND_FILE"
+ path = "docs/result.pdf"
+
+ monkeypatch.setattr(
+ "sdk.agent_api_wrapper.ServerMessage.validate_json",
+ lambda raw: FakeSendFile(),
+ )
+
+ wrapper = AgentApiWrapper(agent_id="agent-1", base_url="https://agent.example.com", chat_id="7")
+ wrapper.callback = seen.append
+ wrapper._current_queue = None
+
+ # use the wrapper's dispatch branch directly inside _listen test harness
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+
+Expected:
+- FAIL because `RealPlatformClient` ignores attachments
+- FAIL because `AgentApiWrapper` only recognizes text/end/error/disconnect events
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# sdk/real.py
+def _attachment_paths(self, attachments) -> list[str]:
+ if not attachments:
+ return []
+ paths = []
+ for attachment in attachments:
+ path = getattr(attachment, "workspace_path", None)
+ if path:
+ paths.append(path)
+ return paths
+
+async def stream_message(...):
+ attachment_paths = self._attachment_paths(attachments)
+ ...
+ async for event in chat_api.send_message(text, attachments=attachment_paths):
+ if hasattr(event, "path"):
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=False,
+ )
+ continue
+ yield MessageChunk(...)
+```
+
+```python
+# sdk/agent_api_wrapper.py
+from lambda_agent_api.server import (
+ MsgError,
+ MsgEventCustomUpdate,
+ MsgEventEnd,
+ MsgEventSendFile,
+ MsgEventTextChunk,
+ MsgEventToolCallChunk,
+ MsgEventToolResult,
+ MsgGracefulDisconnect,
+ ServerMessage,
+)
+
+KNOWN_STREAM_EVENTS = (
+ MsgEventTextChunk,
+ MsgEventToolCallChunk,
+ MsgEventToolResult,
+ MsgEventCustomUpdate,
+ MsgEventSendFile,
+ MsgEventEnd,
+)
+
+if isinstance(outgoing_msg, KNOWN_STREAM_EVENTS):
+ if isinstance(outgoing_msg, MsgEventEnd):
+ self.last_tokens_used = outgoing_msg.tokens_used
+ if self._current_queue:
+ await self._current_queue.put(outgoing_msg)
+ elif self.callback:
+ self.callback(outgoing_msg)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py
+git commit -m "feat: support attachment paths and file events in real sdk bridge"
+```
+
+### Task 3: Implement Matrix Shared-Workspace Receive/Send File Flow
+
+**Files:**
+- Create: `adapter/matrix/files.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_files.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_files.py
+from pathlib import Path
+
+import pytest
+
+from adapter.matrix.files import build_workspace_attachment_path
+
+
+def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path):
+ rel_path, abs_path = build_workspace_attachment_path(
+ workspace_root=tmp_path,
+ matrix_user_id="@alice:example.org",
+ room_id="!room:example.org",
+ filename="report.pdf",
+ timestamp="20260420-153000",
+ )
+
+ assert rel_path == "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf"
+ assert abs_path == tmp_path / rel_path
+```
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_bot_downloads_matrix_file_to_workspace_before_dispatch(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": "matrix:ctx-1",
+ },
+ )
+ 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="Посмотри",
+ msgtype="m.file",
+ url="mxc://server/id",
+ mimetype="application/pdf",
+ replyto_event_id=None,
+ )
+
+ await bot.on_room_message(room, event)
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert dispatched.attachments[0].workspace_path.endswith(".pdf")
+```
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_send_outgoing_uploads_file_attachment_to_matrix(tmp_path):
+ path = tmp_path / "result.txt"
+ path.write_text("ready")
+ client = SimpleNamespace(
+ upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
+ room_send=AsyncMock(),
+ )
+
+ await send_outgoing(
+ client,
+ "!room:example.org",
+ OutgoingMessage(
+ chat_id="!room:example.org",
+ text="Файл готов",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="result.txt",
+ mime_type="text/plain",
+ workspace_path=str(path),
+ )
+ ],
+ ),
+ )
+
+ client.upload.assert_awaited()
+ client.room_send.assert_awaited()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected:
+- FAIL because `adapter.matrix.files` does not exist
+- FAIL because Matrix bot does not persist files before dispatch
+- FAIL because `send_outgoing(...)` only sends text
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/files.py
+from __future__ import annotations
+
+from pathlib import Path
+from datetime import UTC, datetime
+import re
+
+from core.protocol import Attachment
+
+
+def _sanitize_component(value: str) -> str:
+ stripped = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
+ return stripped.strip("._-") or "unknown"
+
+
+def build_workspace_attachment_path(
+ *,
+ workspace_root: Path,
+ matrix_user_id: str,
+ room_id: str,
+ filename: str,
+ timestamp: str | None = None,
+) -> tuple[str, Path]:
+ stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
+ safe_user = _sanitize_component(matrix_user_id.lstrip("@"))
+ safe_room = _sanitize_component(room_id.lstrip("!"))
+ safe_name = _sanitize_component(filename) or "attachment.bin"
+ rel_path = Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
+ return rel_path.as_posix(), workspace_root / rel_path
+```
+
+```python
+# adapter/matrix/bot.py
+from adapter.matrix.files import download_matrix_attachments, load_workspace_attachment
+
+...
+incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
+if isinstance(incoming, IncomingMessage) and incoming.attachments:
+ incoming = await self._materialize_attachments(room.room_id, sender, incoming)
+...
+
+async def _materialize_attachments(...):
+ workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
+ attachments = await download_matrix_attachments(...)
+ return IncomingMessage(..., attachments=attachments, ...)
+```
+
+```python
+# adapter/matrix/bot.py
+if isinstance(event, OutgoingMessage) and event.attachments:
+ for attachment in event.attachments:
+ if attachment.workspace_path:
+ await _send_matrix_file(client, room_id, attachment)
+ if event.text:
+ await client.room_send(...)
+ return
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/files.py adapter/matrix/bot.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: add matrix shared-workspace file receive and send flow"
+```
+
+### Task 4: Make Shared Workspace the Default Local Runtime
+
+**Files:**
+- Modify: `docker-compose.yml`
+- Modify: `README.md`
+- Modify: `.env.example`
+
+- [ ] **Step 1: Write the failing configuration checks**
+
+```bash
+python - <<'PY'
+from pathlib import Path
+text = Path("docker-compose.yml").read_text()
+assert "platform-agent" in text
+assert "/workspace" in text
+assert "matrix-bot" in text
+PY
+```
+
+```bash
+python - <<'PY'
+from pathlib import Path
+readme = Path("README.md").read_text()
+assert "docker compose up" in readme
+assert "/workspace" in readme
+assert "platform-agent" in readme
+PY
+```
+
+- [ ] **Step 2: Run checks to verify they fail**
+
+Run: `python - <<'PY' ... PY`
+
+Expected:
+- FAIL because root compose only defines `matrix-bot`
+- FAIL because README still documents standalone `uvicorn` launch and old WS route
+
+- [ ] **Step 3: Write minimal implementation**
+
+```yaml
+# docker-compose.yml
+services:
+ platform-agent:
+ build:
+ context: ./external/platform-agent
+ target: development
+ additional_contexts:
+ agent_api: ./external/platform-agent_api
+ env_file:
+ - ./external/platform-agent/.env
+ volumes:
+ - workspace:/workspace
+ - ./external/platform-agent/src:/app/src
+ - ./external/platform-agent_api:/agent_api
+ ports:
+ - "8000:8000"
+
+ matrix-bot:
+ build: .
+ env_file: .env
+ depends_on:
+ - platform-agent
+ volumes:
+ - workspace:/workspace
+ restart: unless-stopped
+
+volumes:
+ workspace:
+```
+
+```env
+# .env.example
+AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/0/
+AGENT_BASE_URL=http://platform-agent:8000
+SURFACES_WORKSPACE_DIR=/workspace
+MATRIX_PLATFORM_BACKEND=real
+```
+
+```md
+# README.md
+- make the root `docker compose up` path the primary local runtime
+- describe shared `/workspace` as the file contract
+- remove the statement that real backend is text-only and has no attachments
+- replace the old standalone `uvicorn` instructions with compose-first instructions
+```
+
+- [ ] **Step 4: Run checks to verify they pass**
+
+Run: `python - <<'PY' ... PY`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docker-compose.yml README.md .env.example
+git commit -m "chore: make shared workspace runtime the default local setup"
+```
+
+## Self-Review
+
+- Spec coverage:
+ - shared `/workspace` runtime: Task 4
+ - incoming Matrix file persistence: Task 3
+ - attachment path propagation to agent API: Tasks 1-2
+ - outbound `send_file` flow: Tasks 2-3
+ - future-surface-friendly attachment contract: Task 1
+- Placeholder scan:
+ - no `TODO`, `TBD`, or “similar to”
+ - each task has explicit test, run, implementation, verify, commit steps
+- Type consistency:
+ - `workspace_path` is introduced in both attachment models and consumed consistently in Tasks 1-3
+ - path-based contract is always relative to `/workspace` until Matrix upload resolution step
+
+## Execution Handoff
+
+User already selected parallel subagent execution. Use subagent-driven development and split ownership like this:
+
+- Worker A: `docker-compose.yml`, `README.md`, `.env.example`
+- Worker B: `sdk/agent_api_wrapper.py`, `sdk/real.py`, `tests/platform/test_real.py`
+- Main session / later worker: `core/protocol.py`, `sdk/interface.py`, `core/handlers/message.py`, `adapter/matrix/files.py`, `adapter/matrix/bot.py`, matrix/core tests
diff --git a/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md b/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
new file mode 100644
index 0000000..cfa8f01
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
@@ -0,0 +1,555 @@
+# Matrix Staged Attachments Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Matrix-side staged attachments so file-only events are stored per `(chat_id, user_id)`, acknowledged in chat, and committed to the agent on the next normal user message.
+
+**Architecture:** Keep the existing shared-workspace file contract unchanged and add a thin Matrix-specific staging layer above it. The Matrix adapter will store staged attachment metadata in `adapter.matrix.store`, parse short staging commands in `adapter.matrix.converter`, and orchestrate commit/remove/list behavior in `adapter.matrix.bot` before calling the existing dispatcher.
+
+**Tech Stack:** Python 3.11, matrix-nio, pytest, pytest-asyncio, in-memory state store, shared `/workspace`
+
+---
+
+## File Structure
+
+- Modify: `adapter/matrix/store.py`
+ Purpose: store staged attachment state per `(room_id, user_id)`.
+- Modify: `adapter/matrix/converter.py`
+ Purpose: parse `!list`, `!remove `, `!remove all` into explicit Matrix-side commands.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: stage file-only events, emit service acknowledgments, process staging commands, and commit staged files with the next normal message.
+- Modify: `tests/adapter/matrix/test_store.py`
+ Purpose: verify staged attachment persistence, ordering, and clear/remove helpers.
+- Modify: `tests/adapter/matrix/test_converter.py`
+ Purpose: verify short staging commands parse correctly.
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+ Purpose: verify Matrix bot staging, list/remove behavior, and commit semantics.
+- Modify: `README.md`
+ Purpose: document the Matrix staging UX and short commands.
+
+### Task 1: Add Per-Chat Staged Attachment Storage
+
+**Files:**
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_store.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_store.py
+from adapter.matrix.store import (
+ add_staged_attachment,
+ clear_staged_attachments,
+ get_staged_attachments,
+ remove_staged_attachment_at,
+)
+
+
+async def test_staged_attachments_roundtrip(store: InMemoryStore):
+ await add_staged_attachment(
+ store,
+ room_id="!r1:example.org",
+ user_id="@alice:example.org",
+ attachment={
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
+ "mime_type": "application/pdf",
+ },
+ )
+
+ assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == [
+ {
+ "filename": "report.pdf",
+ "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
+ "mime_type": "application/pdf",
+ }
+ ]
+
+
+async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
+ await add_staged_attachment(
+ store,
+ room_id="!r1:example.org",
+ user_id="@alice:example.org",
+ attachment={"filename": "a.pdf", "workspace_path": "a.pdf"},
+ )
+ await add_staged_attachment(
+ store,
+ room_id="!r2:example.org",
+ user_id="@alice:example.org",
+ attachment={"filename": "b.pdf", "workspace_path": "b.pdf"},
+ )
+ await add_staged_attachment(
+ store,
+ room_id="!r1:example.org",
+ user_id="@bob:example.org",
+ attachment={"filename": "c.pdf", "workspace_path": "c.pdf"},
+ )
+
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r2:example.org", "@alice:example.org")] == ["b.pdf"]
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@bob:example.org")] == ["c.pdf"]
+
+
+async def test_remove_staged_attachment_by_index(store: InMemoryStore):
+ await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
+ await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})
+
+ removed = await remove_staged_attachment_at(store, "!r1:example.org", "@alice:example.org", 1)
+
+ assert removed["filename"] == "b.pdf"
+ assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
+
+
+async def test_clear_staged_attachments(store: InMemoryStore):
+ await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
+
+ await clear_staged_attachments(store, "!r1:example.org", "@alice:example.org")
+
+ assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == []
+```
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+
+Expected:
+- FAIL because staged attachment helper functions do not exist yet
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/store.py
+STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"
+
+
+def _staged_attachments_key(room_id: str, user_id: str) -> str:
+ return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}"
+
+
+async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
+ return list(await store.get(_staged_attachments_key(room_id, user_id)) or [])
+
+
+async def add_staged_attachment(
+ store: StateStore,
+ room_id: str,
+ user_id: str,
+ attachment: dict,
+) -> None:
+ items = await get_staged_attachments(store, room_id, user_id)
+ items.append(attachment)
+ await store.set(_staged_attachments_key(room_id, user_id), items)
+
+
+async def remove_staged_attachment_at(
+ store: StateStore,
+ room_id: str,
+ user_id: str,
+ index: int,
+) -> dict | None:
+ items = await get_staged_attachments(store, room_id, user_id)
+ if index < 0 or index >= len(items):
+ return None
+ removed = items.pop(index)
+ await store.set(_staged_attachments_key(room_id, user_id), items)
+ return removed
+
+
+async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
+ await store.delete(_staged_attachments_key(room_id, user_id))
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
+git commit -m "feat: add matrix staged attachment state"
+```
+
+### Task 2: Parse Short Staging Commands
+
+**Files:**
+- Modify: `adapter/matrix/converter.py`
+- Test: `tests/adapter/matrix/test_converter.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_converter.py
+async def test_list_command_maps_to_matrix_staging_command():
+ result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_list_attachments"
+ assert result.args == []
+
+
+async def test_remove_all_maps_to_matrix_staging_command():
+ result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["all"]
+
+
+async def test_remove_index_maps_to_matrix_staging_command():
+ result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["2"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`
+
+Expected:
+- FAIL because `!list` and `!remove` still parse as generic unknown commands
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/converter.py
+def from_command(body: str, sender: str, chat_id: str, room_id: str | None = None) -> IncomingEvent:
+ raw = body.lstrip("!").strip()
+ parts = raw.split()
+ command = parts[0].lower() if parts else ""
+ args = parts[1:]
+
+ if command == "list":
+ return IncomingCommand(
+ user_id=sender,
+ platform=PLATFORM,
+ chat_id=chat_id,
+ command="matrix_list_attachments",
+ args=[],
+ )
+
+ if command == "remove":
+ return IncomingCommand(
+ user_id=sender,
+ platform=PLATFORM,
+ chat_id=chat_id,
+ command="matrix_remove_attachment",
+ args=args,
+ )
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
+git commit -m "feat: parse matrix staged attachment commands"
+```
+
+### Task 3: Stage File-Only Events and Handle List/Remove UX
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `adapter/matrix/store.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+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="matrix:!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_awaited_once()
+ assert "Следующее сообщение" in client.room_send.await_args.args[2]["body"]
+
+
+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)
+ 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)
+
+ 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)
+ 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)
+
+ assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected:
+- FAIL because file-only events still go straight to dispatcher
+- FAIL because `!list` and `!remove` have no Matrix-side handling in the bot
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/bot.py
+def _is_staging_command(self, incoming: IncomingEvent) -> bool:
+ return isinstance(incoming, IncomingCommand) and incoming.command in {
+ "matrix_list_attachments",
+ "matrix_remove_attachment",
+ }
+
+
+async def _handle_staging_command(self, room_id: str, user_id: str, incoming: IncomingCommand) -> list[OutgoingMessage]:
+ if incoming.command == "matrix_list_attachments":
+ return [OutgoingMessage(chat_id=incoming.chat_id, text=self._format_staged_attachments(room_id, user_id))]
+ if incoming.command == "matrix_remove_attachment" and incoming.args == ["all"]:
+ await clear_staged_attachments(self.runtime.store, room_id, user_id)
+ return [OutgoingMessage(chat_id=incoming.chat_id, text="Вложения очищены.")]
+```
+
+```python
+# adapter/matrix/bot.py
+if isinstance(incoming, IncomingMessage) and incoming.attachments and not incoming.text:
+ incoming = await self._materialize_incoming_attachments(room.room_id, sender, incoming)
+ await self._stage_attachments(room.room_id, sender, incoming.attachments)
+ await self._send_all(room.room_id, [OutgoingMessage(chat_id=dispatch_chat_id, text=self._format_staged_attachments(room.room_id, sender, with_hint=True))])
+ return
+
+if self._is_staging_command(incoming):
+ outgoing = await self._handle_staging_command(room.room_id, sender, incoming)
+ await self._send_all(room.room_id, outgoing)
+ return
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected: PASS for staging/list/remove behavior
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: add matrix staging list and remove flow"
+```
+
+### Task 4: Commit Staged Files With the Next Normal Message
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/adapter/matrix/test_dispatcher.py`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+async def test_next_normal_message_commits_staged_attachments():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {
+ "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 add_staged_attachment(
+ runtime.store,
+ "!r:example.org",
+ "@alice:example.org",
+ {"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"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected:
+- FAIL because normal text messages do not yet merge staged attachments
+- FAIL because staged items are never preserved/cleared based on commit outcome
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# adapter/matrix/bot.py
+async def _merge_staged_attachments(
+ self,
+ room_id: str,
+ user_id: str,
+ incoming: IncomingMessage,
+) -> IncomingMessage:
+ staged = await get_staged_attachments(self.runtime.store, room_id, user_id)
+ if not staged:
+ return incoming
+ return IncomingMessage(
+ user_id=incoming.user_id,
+ platform=incoming.platform,
+ chat_id=incoming.chat_id,
+ text=incoming.text,
+ reply_to=incoming.reply_to,
+ attachments=[
+ Attachment(
+ type="document",
+ filename=item.get("filename"),
+ mime_type=item.get("mime_type"),
+ workspace_path=item.get("workspace_path"),
+ )
+ for item in staged
+ ],
+ )
+```
+
+```python
+# adapter/matrix/bot.py
+staged_before_dispatch = False
+if isinstance(incoming, IncomingMessage) and incoming.text and not incoming.attachments:
+ staged = await get_staged_attachments(self.runtime.store, room.room_id, sender)
+ if staged:
+ incoming = await self._merge_staged_attachments(room.room_id, sender, incoming)
+ staged_before_dispatch = True
+
+try:
+ outgoing = await self.runtime.dispatcher.dispatch(incoming)
+except PlatformError:
+ ...
+else:
+ if staged_before_dispatch:
+ await clear_staged_attachments(self.runtime.store, room.room_id, sender)
+```
+
+- [ ] **Step 4: Run targeted tests to verify they pass**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Update docs**
+
+Add to `README.md`:
+
+```md
+### Matrix staged attachments
+
+If a Matrix user sends files without a text instruction, the bot stages them per chat and replies with the current list.
+
+- `!list` shows staged files
+- `!remove ` removes one staged file by index
+- `!remove all` clears all staged files
+
+The next normal user message is sent to the agent together with all staged files.
+```
+
+- [ ] **Step 6: Run broader verification**
+
+Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_send_outgoing.py tests/core/test_integration.py tests/platform/test_real.py -q`
+
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py
+git commit -m "feat: commit staged matrix attachments on next message"
+```
+
+## Self-Review
+
+- Spec coverage:
+ - staged per `(chat_id, user_id)`: Task 1
+ - short commands `!list`, `!remove `, `!remove all`: Task 2 and Task 3
+ - file-only events do not invoke agent: Task 3
+ - next normal message commits staged attachments: Task 4
+ - failed commit preserves staged attachments: Task 4
+ - docs update: Task 4
+- Placeholder scan:
+ - no `TODO`, `TBD`, or deferred behavior left in task steps
+- Type consistency:
+ - staged storage uses `dict` records with `filename`, `workspace_path`, `mime_type`
+ - bot reconstructs `core.protocol.Attachment` from those same keys
diff --git a/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md b/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md
new file mode 100644
index 0000000..b1984ec
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md
@@ -0,0 +1,540 @@
+# Transport Layer Thin Adapter Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the current custom transport behavior with a thin upstream-compatible adapter so Matrix uses `platform-agent_api.AgentApi` almost as-is and keeps only integration concerns on the `surfaces` side.
+
+**Architecture:** Keep a tiny `AgentApiWrapper` only as a construction/factory shim (`base_url` normalization + `for_chat(chat_id)`). Move all resilience logic that still belongs to `surfaces` into `RealPlatformClient`: per-chat client caching, per-chat send locks, disconnect-on-failure, and `PlatformError` mapping. Delete all custom stream semantics from the wrapper so bugs in streaming can be attributed cleanly to either upstream or our integration layer.
+
+**Tech Stack:** Python 3.11, `aiohttp`, upstream `lambda_agent_api`, `pytest`, `pytest-asyncio`, `ruff`
+
+---
+
+## File Structure
+
+- Modify: `sdk/agent_api_wrapper.py`
+ Purpose: shrink the wrapper to a thin constructor/factory shim with no custom `_listen()` or `send_message()` logic.
+- Modify: `sdk/real.py`
+ Purpose: keep integration-only behavior: cached per-chat clients, chat locks, attachment forwarding, and failure cleanup.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: instantiate the wrapper with the modern `base_url` path instead of a raw `url`, matching the pinned upstream API.
+- Modify: `tests/platform/test_real.py`
+ Purpose: remove tests that encode custom wrapper semantics and replace them with tests that prove only the intended integration guarantees.
+- Modify: `README.md`
+ Purpose: document that the transport layer now uses the pinned upstream `platform-agent_api` client semantics directly, with only a thin local adapter.
+
+### Task 1: Shrink `AgentApiWrapper` To A Thin Factory Shim
+
+**Files:**
+- Modify: `sdk/agent_api_wrapper.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Replace wrapper-only behavior tests with thin-wrapper tests**
+
+Update `tests/platform/test_real.py` so it no longer asserts any custom post-END drain or wrapper-owned timeout behavior. Replace those tests with the following:
+
+```python
+def test_agent_api_wrapper_normalizes_base_url_and_uses_modern_constructor(monkeypatch):
+ captured = {}
+
+ def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
+ captured["agent_id"] = agent_id
+ captured["base_url"] = base_url
+ captured["chat_id"] = chat_id
+
+ monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
+
+ wrapper = AgentApiWrapper(
+ agent_id="agent-1",
+ base_url="ws://platform-agent:8000/v1/agent_ws/",
+ chat_id="41",
+ )
+
+ assert wrapper.chat_id == "41"
+ assert wrapper._base_url == "ws://platform-agent:8000"
+ assert captured == {
+ "agent_id": "agent-1",
+ "base_url": "ws://platform-agent:8000",
+ "chat_id": "41",
+ }
+
+
+def test_agent_api_wrapper_for_chat_reuses_normalized_base_url(monkeypatch):
+ init_calls = []
+
+ def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
+ self.id = agent_id
+ self.chat_id = chat_id
+ self.url = base_url
+ init_calls.append((agent_id, base_url, chat_id))
+
+ monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
+
+ root = AgentApiWrapper(
+ agent_id="agent-1",
+ base_url="http://platform-agent:8000/v1/agent_ws/",
+ chat_id="1",
+ )
+
+ child = root.for_chat("99")
+
+ assert child is not root
+ assert child.chat_id == "99"
+ assert child._base_url == "http://platform-agent:8000"
+ assert init_calls == [
+ ("agent-1", "http://platform-agent:8000", "1"),
+ ("agent-1", "http://platform-agent:8000", "99"),
+ ]
+```
+
+- [ ] **Step 2: Run tests to verify old assumptions fail**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "recovers_late_text_after_first_end or times_out_on_idle_stream or normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"'
+```
+
+Expected:
+
+- FAIL because the old wrapper-behavior tests still exist
+- FAIL or SKIP for the new thin-wrapper tests until code/tests are aligned
+
+- [ ] **Step 3: Replace `sdk/agent_api_wrapper.py` with a thin wrapper**
+
+Rewrite `sdk/agent_api_wrapper.py` to the minimal implementation below:
+
+```python
+from __future__ import annotations
+
+import inspect
+import re
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.agent_api import AgentApi # noqa: E402
+
+
+class AgentApiWrapper(AgentApi):
+ """Thin construction/factory shim over the pinned upstream AgentApi."""
+
+ def __init__(
+ self,
+ agent_id: str,
+ base_url: str,
+ *,
+ chat_id: int | str = 0,
+ **kwargs,
+ ) -> None:
+ self._base_url = self._normalize_base_url(base_url)
+ self._init_kwargs = dict(kwargs)
+ self.chat_id = chat_id
+ if not self._supports_modern_constructor():
+ raise RuntimeError("Pinned platform-agent_api is expected to support base_url + chat_id")
+
+ super().__init__(
+ agent_id=agent_id,
+ base_url=self._base_url,
+ chat_id=chat_id,
+ **kwargs,
+ )
+
+ @staticmethod
+ def _supports_modern_constructor() -> bool:
+ try:
+ parameters = inspect.signature(AgentApi.__init__).parameters
+ except (TypeError, ValueError):
+ return False
+ return "base_url" in parameters and "chat_id" in parameters
+
+ @staticmethod
+ def _normalize_base_url(base_url: str) -> str:
+ parsed = urlsplit(base_url)
+ path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
+
+ def for_chat(self, chat_id: int | str) -> "AgentApiWrapper":
+ return type(self)(
+ agent_id=self.id,
+ base_url=self._base_url,
+ chat_id=chat_id,
+ **self._init_kwargs,
+ )
+```
+
+- [ ] **Step 4: Run the wrapper-focused tests**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"'
+```
+
+Expected:
+
+- PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdk/agent_api_wrapper.py tests/platform/test_real.py
+git commit -m "refactor: shrink agent api wrapper to thin adapter"
+```
+
+### Task 2: Simplify `RealPlatformClient` To The Pinned Modern API
+
+**Files:**
+- Modify: `sdk/real.py`
+- Modify: `adapter/matrix/bot.py`
+- Test: `tests/platform/test_real.py`
+
+- [ ] **Step 1: Add failing integration tests for the desired thin-adapter contract**
+
+Extend `tests/platform/test_real.py` with these assertions:
+
+```python
+@pytest.mark.asyncio
+async def test_real_platform_client_passes_attachments_to_modern_send_message():
+ agent_api = FakeAgentApiFactory()
+ client = RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ attachment = Attachment(
+ type="document",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ workspace_path="surfaces/matrix/u1/r1/inbox/report.pdf",
+ )
+
+ result = await client.send_message(
+ "@alice:example.org",
+ "chat-1",
+ "read this",
+ attachments=[attachment],
+ )
+
+ assert result.response == "read this"
+ assert agent_api.instances["chat-1"].calls == [
+ ("read this", ["surfaces/matrix/u1/r1/inbox/report.pdf"])
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_disconnects_chat_after_agent_exception():
+ class ErroringChatAgentApi:
+ def __init__(self, chat_id: str) -> None:
+ self.chat_id = chat_id
+ self.connect_calls = 0
+ self.close_calls = 0
+
+ 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, attachments: list[str] | None = None):
+ raise agent_api_wrapper_module.AgentException("INTERNAL_ERROR", "boom")
+ yield
+
+ agent_api = FakeAgentApiFactory()
+ erroring = ErroringChatAgentApi("chat-1")
+ agent_api.for_chat = lambda chat_id: erroring
+ client = RealPlatformClient(
+ agent_api=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ with pytest.raises(PlatformError, match="boom") as exc_info:
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+
+ assert exc_info.value.code == "INTERNAL_ERROR"
+ assert erroring.close_calls == 1
+ assert "chat-1" not in client._chat_apis
+```
+
+- [ ] **Step 2: Run tests to verify they fail before simplification**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception"'
+```
+
+Expected:
+
+- FAIL until `sdk/real.py` and Matrix runtime wiring are aligned with the pinned modern API
+
+- [ ] **Step 3: Simplify `sdk/real.py` and Matrix runtime construction**
+
+Make these exact edits:
+
+```python
+# adapter/matrix/bot.py
+def _build_platform_from_env() -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if backend == "real":
+ base_url = os.environ["AGENT_BASE_URL"]
+ return RealPlatformClient(
+ agent_api=AgentApiWrapper(agent_id="matrix-bot", base_url=base_url),
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ return MockPlatformClient()
+```
+
+```python
+# sdk/real.py
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from pathlib import Path
+
+from sdk.agent_api_wrapper import AgentApiWrapper
+from sdk.interface import (
+ Attachment,
+ MessageChunk,
+ MessageResponse,
+ PlatformClient,
+ PlatformError,
+ User,
+ UserSettings,
+)
+from sdk.prototype_state import PrototypeStateStore
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_api: AgentApiWrapper,
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ ) -> None:
+ self._agent_api = agent_api
+ self._prototype_state = prototype_state
+ self._platform = platform
+ self._chat_apis: dict[str, AgentApiWrapper] = {}
+ self._chat_api_lock = asyncio.Lock()
+ self._chat_send_locks: dict[str, asyncio.Lock] = {}
+
+ @property
+ def agent_api(self) -> AgentApiWrapper:
+ return self._agent_api
+
+ async def _get_chat_api(self, chat_id: str) -> AgentApiWrapper:
+ chat_key = str(chat_id)
+ chat_api = self._chat_apis.get(chat_key)
+ if chat_api is None:
+ async with self._chat_api_lock:
+ chat_api = self._chat_apis.get(chat_key)
+ if chat_api is None:
+ chat_api = self._agent_api.for_chat(chat_key)
+ await chat_api.connect()
+ self._chat_apis[chat_key] = chat_api
+ return chat_api
+
+ def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock:
+ chat_key = str(chat_id)
+ lock = self._chat_send_locks.get(chat_key)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._chat_send_locks[chat_key] = lock
+ return lock
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ response_parts: list[str] = []
+ tokens_used = 0
+ sent_attachments: list[Attachment] = []
+ message_id = user_id
+
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = await self._get_chat_api(chat_id)
+ try:
+ async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)):
+ if hasattr(event, "text"):
+ response_parts.append(event.text)
+ elif event.__class__.__name__ == "MsgEventEnd":
+ tokens_used = getattr(event, "tokens_used", 0)
+ elif "SEND_FILE" in getattr(getattr(event, "type", None), "value", str(getattr(event, "type", ""))):
+ attachment = self._attachment_from_send_file_event(event)
+ if attachment is not None:
+ sent_attachments.append(attachment)
+ except Exception as exc:
+ await self._handle_chat_api_failure(chat_id, exc)
+
+ await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
+
+ return MessageResponse(
+ message_id=message_id,
+ response="".join(response_parts),
+ tokens_used=tokens_used,
+ finished=True,
+ attachments=sent_attachments,
+ )
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = await self._get_chat_api(chat_id)
+ try:
+ async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)):
+ if hasattr(event, "text"):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ elif event.__class__.__name__ == "MsgEventEnd":
+ tokens_used = getattr(event, "tokens_used", 0)
+ await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=tokens_used,
+ )
+ except Exception as exc:
+ await self._handle_chat_api_failure(chat_id, exc)
+
+ async def disconnect_chat(self, chat_id: str) -> None:
+ chat_key = str(chat_id)
+ chat_api = self._chat_apis.pop(chat_key, None)
+ self._chat_send_locks.pop(chat_key, None)
+ if chat_api is not None:
+ await chat_api.close()
+
+ async def close(self) -> None:
+ for chat_api in list(self._chat_apis.values()):
+ await chat_api.close()
+ self._chat_apis.clear()
+ self._chat_send_locks.clear()
+
+ async def _handle_chat_api_failure(self, chat_id: str, exc: Exception) -> None:
+ await self.disconnect_chat(chat_id)
+ code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
+ raise PlatformError(str(exc), code=code) from exc
+
+ @staticmethod
+ def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
+ if not attachments:
+ return []
+ return [attachment.workspace_path for attachment in attachments if attachment.workspace_path]
+```
+
+- [ ] **Step 4: Run the focused transport tests**
+
+Run:
+
+```bash
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception or wraps_connection_closed_as_platform_error or reconnects_after_closed_chat_api"'
+```
+
+Expected:
+
+- PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/bot.py sdk/real.py tests/platform/test_real.py
+git commit -m "refactor: use upstream transport semantics in real client"
+```
+
+### Task 3: Remove Custom Transport Assumptions From Tests And Docs
+
+**Files:**
+- Modify: `tests/platform/test_real.py`
+- Modify: `README.md`
+
+- [ ] **Step 1: Delete tests that encode wrapper-owned stream semantics**
+
+Remove any tests that assert:
+
+- late text is recovered after the first `END`
+- duplicate `END` is repaired inside our wrapper
+- wrapper-owned idle timeout semantics
+
+The file should keep only tests for:
+
+- wrapper construction/factory behavior
+- per-chat client reuse
+- reconnect/disconnect after failure
+- attachment forwarding
+- per-chat send locking
+
+- [ ] **Step 2: Update README transport description**
+
+Add this text to the Matrix runtime/backend section in `README.md`:
+
+```md
+Transport layer note:
+
+- `surfaces` now uses the pinned upstream `platform-agent_api.AgentApi` stream semantics directly
+- local code keeps only a thin adapter for client construction and per-chat client factories
+- request serialization, disconnect-on-failure, and `PlatformError` mapping remain in `sdk/real.py`
+- `surfaces` no longer performs local post-END stream reconstruction
+```
+
+- [ ] **Step 3: Run the full verification set**
+
+Run:
+
+```bash
+uv run ruff check adapter/matrix sdk tests/platform/test_real.py
+/bin/zsh -lc 'PYTHONPATH=. uv run pytest tests/adapter/matrix -q'
+/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q'
+```
+
+Expected:
+
+- `ruff` reports `All checks passed!`
+- Matrix adapter tests PASS
+- `tests/platform/test_real.py` PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add README.md tests/platform/test_real.py
+git commit -m "test: remove custom transport semantics assumptions"
+```
+
+---
+
+## Self-Review
+
+- Spec coverage:
+ - thin adapter target: covered by Task 1
+ - integration-only `RealPlatformClient`: covered by Task 2
+ - removal of custom stream semantics assumptions: covered by Task 3
+ - re-verification after cleanup: covered by Task 3
+
+- Placeholder scan:
+ - no `TODO`, `TBD`, or deferred implementation placeholders remain in task steps
+
+- Type consistency:
+ - `AgentApiWrapper` remains the construction/factory type used by `RealPlatformClient`
+ - failure mapping still terminates in `PlatformError`
+ - attachment forwarding consistently uses `attachments: list[str]`
diff --git a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md
new file mode 100644
index 0000000..a5227e8
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md
@@ -0,0 +1,855 @@
+# Matrix Multi-Agent Routing And Restart State Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart.
+
+**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart.
+
+**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio
+
+---
+
+## File Structure
+
+- Create: `adapter/matrix/agent_registry.py`
+ Purpose: load and validate the YAML agent registry used by Matrix runtime.
+- Create: `adapter/matrix/routed_platform.py`
+ Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances.
+- Create: `adapter/matrix/handlers/agent.py`
+ Purpose: implement `!agent` listing and selection behavior.
+- Create: `tests/adapter/matrix/test_agent_registry.py`
+ Purpose: cover YAML loading and registry validation.
+- Create: `tests/adapter/matrix/test_routed_platform.py`
+ Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol.
+- Create: `tests/adapter/matrix/test_agent_handler.py`
+ Purpose: cover `!agent` UX and persistence of `selected_agent_id`.
+- Create: `tests/adapter/matrix/test_restart_persistence.py`
+ Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite.
+- Create: `config/matrix-agents.example.yaml`
+ Purpose: document the expected agent registry format.
+- Modify: `pyproject.toml`
+ Purpose: add YAML parsing dependency required by the runtime registry loader.
+- Modify: `.env.example`
+ Purpose: document the config path env var for the Matrix agent registry.
+- Modify: `README.md`
+ Purpose: document the new config file, `!agent`, and restart persistence expectations.
+- Modify: `adapter/matrix/store.py`
+ Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics.
+- Modify: `adapter/matrix/bot.py`
+ Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch.
+- Modify: `adapter/matrix/handlers/__init__.py`
+ Purpose: register the new `!agent` command.
+- Modify: `adapter/matrix/handlers/chat.py`
+ Purpose: require a selected agent for `!new` and bind new rooms to that agent.
+- Modify: `adapter/matrix/handlers/context_commands.py`
+ Purpose: keep context commands compatible with local chat ids and routed platform delegation.
+- Modify: `adapter/matrix/handlers/settings.py`
+ Purpose: expose `!agent` in help text.
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+ Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics.
+- Modify: `tests/adapter/matrix/test_context_commands.py`
+ Purpose: keep load/reset/context flows aligned with the routed platform facade.
+
+---
+
+### Task 1: Add The Agent Registry And Configuration Wiring
+
+**Files:**
+- Create: `adapter/matrix/agent_registry.py`
+- Create: `tests/adapter/matrix/test_agent_registry.py`
+- Create: `config/matrix-agents.example.yaml`
+- Modify: `pyproject.toml`
+- Modify: `.env.example`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing registry tests**
+
+```python
+# tests/adapter/matrix/test_agent_registry.py
+from pathlib import Path
+
+import pytest
+
+from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
+
+
+def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n",
+ encoding="utf-8",
+ )
+
+ registry = load_agent_registry(path)
+
+ assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
+ assert registry.get("agent-1").label == "Analyst"
+
+
+def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-1\n"
+ " label: Duplicate\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="duplicate agent id"):
+ load_agent_registry(path)
+```
+
+- [ ] **Step 2: Run the registry tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
+
+Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`.
+
+- [ ] **Step 3: Add the YAML dependency and implement the registry loader**
+
+```toml
+# pyproject.toml
+dependencies = [
+ "aiogram>=3.4,<4",
+ "matrix-nio>=0.21",
+ "pydantic>=2.5",
+ "structlog>=24.1",
+ "python-dotenv>=1.0",
+ "httpx>=0.27",
+ "aiohttp>=3.9",
+ "PyYAML>=6.0",
+]
+```
+
+```python
+# adapter/matrix/agent_registry.py
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+
+class AgentRegistryError(ValueError):
+ pass
+
+
+@dataclass(frozen=True)
+class AgentDefinition:
+ agent_id: str
+ label: str
+
+
+class AgentRegistry:
+ def __init__(self, agents: list[AgentDefinition]) -> None:
+ self.agents = agents
+ self._by_id = {agent.agent_id: agent for agent in agents}
+
+ def get(self, agent_id: str) -> AgentDefinition:
+ try:
+ return self._by_id[agent_id]
+ except KeyError as exc:
+ raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
+
+
+def load_agent_registry(path: str | Path) -> AgentRegistry:
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
+ entries = raw.get("agents")
+ if not isinstance(entries, list) or not entries:
+ raise AgentRegistryError("agents registry must contain a non-empty agents list")
+
+ agents: list[AgentDefinition] = []
+ seen: set[str] = set()
+ for entry in entries:
+ agent_id = str(entry.get("id", "")).strip()
+ label = str(entry.get("label", "")).strip()
+ if not agent_id or not label:
+ raise AgentRegistryError("each agent entry requires id and label")
+ if agent_id in seen:
+ raise AgentRegistryError(f"duplicate agent id: {agent_id}")
+ seen.add(agent_id)
+ agents.append(AgentDefinition(agent_id=agent_id, label=label))
+ return AgentRegistry(agents)
+```
+
+- [ ] **Step 4: Add the example config and runtime wiring docs**
+
+```yaml
+# config/matrix-agents.example.yaml
+agents:
+ - id: agent-1
+ label: Analyst
+ - id: agent-2
+ label: Research
+```
+
+```env
+# .env.example
+MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
+```
+
+```markdown
+# README.md
+1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml`
+2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml`
+3. Use `!agent` in Matrix to select the active upstream agent
+```
+
+- [ ] **Step 5: Run the registry tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py
+git commit -m "feat: add matrix agent registry loader"
+```
+
+---
+
+### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient`
+
+**Files:**
+- Create: `adapter/matrix/routed_platform.py`
+- Create: `tests/adapter/matrix/test_routed_platform.py`
+- Modify: `adapter/matrix/bot.py`
+
+- [ ] **Step 1: Write the failing routed-platform tests**
+
+```python
+# tests/adapter/matrix/test_routed_platform.py
+import pytest
+
+from adapter.matrix.routed_platform import RoutedPlatformClient
+from adapter.matrix.store import set_room_meta
+from core.chat import ChatManager
+from core.store import InMemoryStore
+from sdk.interface import MessageResponse
+from sdk.prototype_state import PrototypeStateStore
+
+
+class FakeDelegate:
+ def __init__(self, agent_id: str) -> None:
+ self.agent_id = agent_id
+ self.calls = []
+
+ async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
+ self.calls.append((user_id, chat_id, text, attachments))
+ return MessageResponse(
+ message_id=user_id,
+ response=f"{self.agent_id}:{text}",
+ tokens_used=0,
+ finished=True,
+ )
+
+ async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
+ return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name)
+
+ async def get_settings(self, user_id: str):
+ return await PrototypeStateStore().get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action):
+ return None
+
+
+@pytest.mark.asyncio
+async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+
+ delegates = {"agent-2": FakeDelegate("agent-2")}
+ platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
+
+ response = await platform.send_message("u1", "C1", "hello")
+
+ assert response.response == "agent-2:hello"
+ assert delegates["agent-2"].calls == [("u1", "41", "hello", None)]
+```
+
+- [ ] **Step 2: Run the routed-platform tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
+
+Expected: FAIL with `ImportError` for `RoutedPlatformClient`.
+
+- [ ] **Step 3: Implement the routing facade and integrate runtime construction**
+
+```python
+# adapter/matrix/routed_platform.py
+from __future__ import annotations
+
+from sdk.interface import PlatformClient
+
+
+class RoutedPlatformClient(PlatformClient):
+ def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None:
+ self._store = store
+ self._chat_mgr = chat_mgr
+ self._delegates = delegates
+
+ async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
+ ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id)
+ if ctx is None:
+ raise ValueError(f"Chat {local_chat_id} not found for {user_id}")
+ room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}")
+ if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"):
+ raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target")
+ delegate = self._delegates[room_meta["agent_id"]]
+ return delegate, str(room_meta["platform_chat_id"])
+
+ async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
+ delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
+ return await delegate.send_message(user_id, platform_chat_id, text, attachments)
+
+ async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
+ delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
+ async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
+ yield chunk
+
+ async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
+ first_delegate = next(iter(self._delegates.values()))
+ return await first_delegate.get_or_create_user(external_id, platform, display_name)
+
+ async def get_settings(self, user_id: str):
+ first_delegate = next(iter(self._delegates.values()))
+ return await first_delegate.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action):
+ first_delegate = next(iter(self._delegates.values()))
+ await first_delegate.update_settings(user_id, action)
+```
+
+```python
+# adapter/matrix/bot.py
+from adapter.matrix.agent_registry import load_agent_registry
+from adapter.matrix.routed_platform import RoutedPlatformClient
+
+
+def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
+ backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
+ if backend != "real":
+ return MockPlatformClient()
+
+ registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"])
+ delegates = {
+ agent.agent_id: RealPlatformClient(
+ agent_id=agent.agent_id,
+ agent_base_url=_agent_base_url_from_env(),
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ for agent in registry.agents
+ }
+ return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
+
+
+def build_runtime(...):
+ store = store or InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ platform = platform or _build_platform_from_env(store, chat_mgr)
+ auth_mgr = AuthManager(platform, store)
+ settings_mgr = SettingsManager(platform, store)
+ dispatcher = EventDispatcher(
+ platform=platform,
+ chat_mgr=chat_mgr,
+ auth_mgr=auth_mgr,
+ settings_mgr=settings_mgr,
+ )
+```
+
+- [ ] **Step 4: Run the routed-platform tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py
+git commit -m "feat: add matrix routed platform facade"
+```
+
+---
+
+### Task 3: Add `!agent` Selection And Durable User Agent State
+
+**Files:**
+- Create: `adapter/matrix/handlers/agent.py`
+- Create: `tests/adapter/matrix/test_agent_handler.py`
+- Modify: `adapter/matrix/store.py`
+- Modify: `adapter/matrix/handlers/__init__.py`
+- Modify: `adapter/matrix/handlers/settings.py`
+
+- [ ] **Step 1: Write the failing agent-handler tests**
+
+```python
+# tests/adapter/matrix/test_agent_handler.py
+import pytest
+
+from adapter.matrix.handlers.agent import make_handle_agent
+from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta
+from core.protocol import IncomingCommand
+from core.store import InMemoryStore
+
+
+class FakeRegistry:
+ def __init__(self) -> None:
+ self.agents = [
+ type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(),
+ type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_agent_command_lists_available_agents():
+ handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry())
+ result = await handler(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]),
+ None,
+ None,
+ None,
+ None,
+ )
+ assert "1. Analyst" in result[0].text
+ assert "2. Research" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_agent_command_persists_selected_agent_and_binds_unbound_room():
+ store = InMemoryStore()
+ await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"})
+ handler = make_handle_agent(store=store, registry=FakeRegistry())
+ chat_mgr = type(
+ "ChatMgr",
+ (),
+ {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())},
+ )()
+
+ await handler(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]),
+ None,
+ None,
+ chat_mgr,
+ None,
+ )
+
+ assert await get_selected_agent_id(store, "u1") == "agent-2"
+ room_meta = await get_room_meta(store, "!room:example.org")
+ assert room_meta["agent_id"] == "agent-2"
+```
+
+- [ ] **Step 2: Run the agent-handler tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
+
+Expected: FAIL with missing handler or store helpers.
+
+- [ ] **Step 3: Add durable store helpers and implement `!agent`**
+
+```python
+# adapter/matrix/store.py
+async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
+ meta = await get_user_meta(store, matrix_user_id) or {}
+ value = meta.get("selected_agent_id")
+ return str(value) if value else None
+
+
+async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None:
+ meta = await get_user_meta(store, matrix_user_id) or {}
+ meta["selected_agent_id"] = agent_id
+ await set_user_meta(store, matrix_user_id, meta)
+
+
+async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
+ meta = dict(await get_room_meta(store, room_id) or {})
+ meta["agent_id"] = agent_id
+ await set_room_meta(store, room_id, meta)
+```
+
+```python
+# adapter/matrix/handlers/agent.py
+from __future__ import annotations
+
+from adapter.matrix.store import (
+ get_room_meta,
+ get_selected_agent_id,
+ next_platform_chat_id,
+ set_platform_chat_id,
+ set_room_agent_id,
+ set_selected_agent_id,
+)
+from core.protocol import IncomingCommand, OutgoingMessage
+
+
+def make_handle_agent(store, registry):
+ async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr):
+ if not event.args:
+ current = await get_selected_agent_id(store, event.user_id)
+ lines = ["Доступные агенты:"]
+ for index, agent in enumerate(registry.agents, start=1):
+ marker = " (текущий)" if agent.agent_id == current else ""
+ lines.append(f"{index}. {agent.label}{marker}")
+ lines.append("")
+ lines.append("Выбери агента: !agent <номер>")
+ return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
+
+ agent = registry.agents[int(event.args[0]) - 1]
+ await set_selected_agent_id(store, event.user_id, agent.agent_id)
+ ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None
+ if ctx is not None:
+ room_meta = await get_room_meta(store, ctx.surface_ref)
+ if room_meta is not None and not room_meta.get("agent_id"):
+ await set_room_agent_id(store, ctx.surface_ref, agent.agent_id)
+ if not room_meta.get("platform_chat_id"):
+ await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store))
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")]
+ return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")]
+
+ return handle_agent
+```
+
+- [ ] **Step 4: Register the command and update help text**
+
+```python
+# adapter/matrix/handlers/__init__.py
+from adapter.matrix.handlers.agent import make_handle_agent
+
+dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
+```
+
+```python
+# adapter/matrix/handlers/settings.py
+HELP_TEXT = "\n".join(
+ [
+ "Команды",
+ "",
+ "!agent выбрать активного агента",
+ "!new [название] создать новый чат",
+ "!chats список активных чатов",
+ "!rename <название> переименовать текущий чат",
+ "!archive архивировать текущий чат",
+ "!context показать текущее состояние контекста",
+ "!save [имя] сохранить текущий контекст",
+ "!load показать сохранённые контексты",
+ ]
+)
+```
+
+- [ ] **Step 5: Run the agent-handler tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py
+git commit -m "feat: add matrix agent selection command"
+```
+
+---
+
+### Task 4: Bind Rooms Correctly And Block Stale Chats
+
+**Files:**
+- Modify: `adapter/matrix/bot.py`
+- Modify: `adapter/matrix/handlers/chat.py`
+- Modify: `adapter/matrix/handlers/context_commands.py`
+- Modify: `tests/adapter/matrix/test_dispatcher.py`
+- Modify: `tests/adapter/matrix/test_context_commands.py`
+
+- [ ] **Step 1: Write the failing dispatcher and context-command tests**
+
+```python
+# tests/adapter/matrix/test_dispatcher.py
+@pytest.mark.asyncio
+async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"})
+
+ await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello"))
+
+ client.room_send.assert_awaited_once()
+ assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower()
+
+
+@pytest.mark.asyncio
+async def test_new_chat_requires_selected_agent_and_binds_room_meta():
+ client = SimpleNamespace(
+ room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
+ room_put_state=AsyncMock(),
+ )
+ runtime = build_runtime(platform=MockPlatformClient(), client=client)
+ await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"})
+
+ result = await runtime.dispatcher.dispatch(
+ IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"])
+ )
+
+ room_meta = await get_room_meta(runtime.store, "!r2:example")
+ assert room_meta["agent_id"] == "agent-2"
+ assert "Создан чат" in result[0].text
+```
+
+```python
+# tests/adapter/matrix/test_context_commands.py
+@pytest.mark.asyncio
+async def test_load_selection_calls_platform_with_local_chat_id():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
+ await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"})
+
+ client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
+ bot = MatrixBot(client, runtime)
+ await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]})
+
+ await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1"))
+
+ platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a"))
+```
+
+- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
+
+Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`.
+
+- [ ] **Step 3: Implement room binding and stale-room checks in runtime**
+
+```python
+# adapter/matrix/bot.py
+from adapter.matrix.store import (
+ get_selected_agent_id,
+ get_room_meta,
+ next_platform_chat_id,
+ set_platform_chat_id,
+ set_room_agent_id,
+)
+
+
+async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]:
+ room_meta = await get_room_meta(self.runtime.store, room_id)
+ selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id)
+ if not selected_agent_id:
+ return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.")
+ if room_meta is None:
+ return room_meta, None
+ if not room_meta.get("agent_id"):
+ await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
+ if not room_meta.get("platform_chat_id"):
+ await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store))
+ room_meta = await get_room_meta(self.runtime.store, room_id)
+ return room_meta, None
+ if room_meta["agent_id"] != selected_agent_id:
+ return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.")
+ return room_meta, None
+```
+
+```python
+# adapter/matrix/bot.py
+local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
+dispatch_chat_id = local_chat_id
+
+if not body.startswith("!"):
+ room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender)
+ if blocking is not None:
+ await self._send_all(room.room_id, [blocking])
+ return
+
+incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
+```
+
+- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`**
+
+```python
+# adapter/matrix/handlers/chat.py
+from adapter.matrix.store import get_selected_agent_id
+
+selected_agent_id = await get_selected_agent_id(store, event.user_id)
+if not selected_agent_id:
+ return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")]
+
+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,
+ "platform_chat_id": platform_chat_id,
+ "agent_id": selected_agent_id,
+ },
+)
+```
+
+```python
+# adapter/matrix/bot.py
+room_meta = await get_room_meta(self.runtime.store, room_id)
+local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id
+
+await self.runtime.platform.send_message(
+ user_id,
+ local_chat_id,
+ LOAD_PROMPT.format(name=name),
+)
+```
+
+- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py
+git commit -m "feat: bind matrix rooms to selected agents"
+```
+
+---
+
+### Task 5: Prove Durable Restart State And Sequence Persistence
+
+**Files:**
+- Create: `tests/adapter/matrix/test_restart_persistence.py`
+- Modify: `adapter/matrix/store.py`
+- Modify: `README.md`
+
+- [ ] **Step 1: Write the failing restart-persistence tests**
+
+```python
+# tests/adapter/matrix/test_restart_persistence.py
+import pytest
+
+from adapter.matrix.store import (
+ get_selected_agent_id,
+ next_platform_chat_id,
+ set_room_meta,
+ set_selected_agent_id,
+)
+from core.store import SQLiteStore
+
+
+@pytest.mark.asyncio
+async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path):
+ db_path = tmp_path / "matrix.db"
+ store = SQLiteStore(str(db_path))
+ await set_selected_agent_id(store, "u1", "agent-2")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+
+ reopened = SQLiteStore(str(db_path))
+ assert await get_selected_agent_id(reopened, "u1") == "agent-2"
+ assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2"
+ assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41"
+
+
+@pytest.mark.asyncio
+async def test_platform_chat_sequence_survives_store_recreation(tmp_path):
+ db_path = tmp_path / "matrix.db"
+ store = SQLiteStore(str(db_path))
+
+ assert await next_platform_chat_id(store) == "1"
+ assert await next_platform_chat_id(store) == "2"
+
+ reopened = SQLiteStore(str(db_path))
+ assert await next_platform_chat_id(reopened) == "3"
+```
+
+- [ ] **Step 2: Run the restart-persistence tests to verify they fail**
+
+Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
+
+Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered.
+
+- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary**
+
+```python
+# adapter/matrix/store.py
+PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
+
+
+async def next_platform_chat_id(store: StateStore) -> str:
+ async with _PLATFORM_CHAT_SEQ_LOCK:
+ data = await store.get(PLATFORM_CHAT_SEQ_KEY)
+ index = int((data or {}).get("next_platform_chat_index", 1))
+ await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1})
+ return str(index)
+```
+
+```markdown
+# README.md
+- Matrix durable state lives in `lambda_matrix.db` and `matrix_store`
+- normal restart is supported only when those paths survive container recreation
+- staged attachments and pending confirmations are intentionally not restored
+```
+
+- [ ] **Step 4: Run the restart-persistence tests to verify they pass**
+
+Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
+
+Expected: PASS
+
+- [ ] **Step 5: Run the combined verification sweep**
+
+Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q`
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py
+git commit -m "test: cover matrix restart state persistence"
+```
+
+---
+
+## Self-Review
+
+### Spec coverage
+
+- Multi-agent agent registry: Task 1
+- Shared `PlatformClient` preserved via routing facade: Task 2
+- `!agent` UX and durable `selected_agent_id`: Task 3
+- Unbound room activation, `!new`, stale room rejection: Task 4
+- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5
+
+### Placeholder scan
+
+- No `TODO`, `TBD`, or “implement later” markers remain.
+- Each task includes exact file paths, tests, commands, and minimal code snippets.
+
+### Type consistency
+
+- `selected_agent_id` lives in user metadata throughout the plan.
+- `agent_id` and `platform_chat_id` live in room metadata throughout the plan.
+- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.
diff --git a/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md b/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md
new file mode 100644
index 0000000..581eb56
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md
@@ -0,0 +1,243 @@
+# Matrix Direct-Agent Prototype Design
+
+## Goal
+
+Ship a working Matrix prototype that talks to the real Lambda agent instead of `MockPlatformClient`, while preserving the current Matrix adapter logic and keeping the code expandable toward future platform versions.
+
+## Scope
+
+This design is for a Matrix-only prototype delivered from this repository on a dedicated branch. It is not a `main` branch rollout and it is not a separate prototype repo.
+
+The design assumes a minimal companion fork of `platform/agent` may be used, but changes there must stay as small as possible.
+
+## Constraints
+
+- Preserve the current Matrix transport logic as much as possible.
+- Keep `core/` unaware of platform immaturity.
+- Avoid broad changes to platform repos.
+- Prefer one narrow patch to `platform/agent` over changes to both `platform/agent` and `platform/agent_api`.
+- Keep the backend boundary reusable for future Telegram or other surfaces.
+- Do not pretend unsupported platform capabilities are real.
+
+## Live Platform Findings
+
+Based on the live repo analysis performed on April 7, 2026:
+
+- `platform/master` is not yet a usable consumer-facing backend for surfaces.
+- `platform/agent` exposes a working WebSocket endpoint for prompt/response exchange.
+- `platform/agent_api` documents and implements text-oriented WebSocket messaging, but the bot does not need to depend on that package directly.
+- `platform/agent` currently hardcodes a single shared backend memory thread via `thread_id = "default"`, which would cause all chats to share context.
+
+## Architecture
+
+The prototype remains in this repo and introduces a new real backend path behind the existing SDK boundary.
+
+### New files
+
+- `sdk/real.py`
+ - Exports `RealPlatformClient`
+ - Implements the existing `PlatformClient` contract from `sdk/interface.py`
+ - Composes the lower-level prototype pieces
+
+- `sdk/agent_session.py`
+ - Owns direct WebSocket communication with the real agent
+ - Manages connection lifecycle, request/response handling, and thread identity
+
+- `sdk/prototype_state.py`
+ - Owns local prototype-only state
+ - Stores user mapping, local settings, and lightweight metadata needed until a real control plane exists
+
+### Responsibility split
+
+- Matrix adapter remains transport-specific only.
+- `core/` continues to depend only on `PlatformClient`.
+- `RealPlatformClient` acts as the anti-corruption layer between the current bot contract and the platform’s incomplete shape.
+- Local control-plane behavior remains explicit and replaceable later.
+
+## Message and Identity Model
+
+Each Matrix chat gets a stable backend session identity.
+
+### Surface identity
+
+- Surface: `matrix`
+- Surface user id: Matrix MXID, for example `@alice:example.org`
+- Surface chat id: logical chat id from `ChatManager`, for example `C1`
+- Surface ref: Matrix room id
+
+### Backend thread identity
+
+Use a deterministic thread key:
+
+`matrix:{matrix_user_id}:{chat_id}`
+
+Example:
+
+`matrix:@alice:example.org:C1`
+
+### Mapping rules
+
+- One Matrix logical chat maps to one backend memory thread.
+- `!new` creates a fresh logical chat and therefore a fresh backend thread.
+- `!rename` only changes display metadata and does not change backend identity.
+- `!archive` stops active use of the thread in the surface, but does not need to delete backend memory in v1.
+
+## Runtime Flow
+
+### Normal message flow
+
+1. Matrix event arrives in an existing room.
+2. Existing Matrix routing resolves room to logical `chat_id`.
+3. `core/handlers/message.py` calls `platform.send_message(...)`.
+4. `RealPlatformClient` derives the backend thread key from `(platform, user_id, chat_id)`.
+5. `AgentSessionClient` sends the prompt to the agent WebSocket using that thread key.
+6. The reply is converted into the existing `MessageResponse` or `MessageChunk` contract.
+7. Matrix sends the final text back to the room.
+
+### Settings flow
+
+For v1, settings remain local:
+
+- `get_settings()` reads from local prototype state
+- `update_settings()` writes to local prototype state
+
+This is intentional. The prototype must not claim settings are backed by the real platform when no such platform API exists yet.
+
+## Feature Matrix
+
+### Real in v1
+
+- `!start`
+- Plain text messaging with the real agent
+- Matrix chat lifecycle already implemented in this repo:
+ - `!new`
+ - `!chats`
+ - `!rename`
+ - `!archive`
+- Per-chat conversation memory, provided the agent accepts dynamic thread identity
+
+### Local in v1
+
+- `!settings`
+- `!skills`
+- `!soul`
+- `!safety`
+- `!status`
+- user registration and local user mapping
+
+### Deferred
+
+- Attachments and file upload to the agent
+- Voice input to the agent
+- Image input to the agent
+- Long-running task callbacks and webhook-style async completion
+- Real control-plane integration through `platform/master`
+
+## Minimal Upstream Change
+
+To avoid shared memory across all conversations, make one narrow change in the forked `platform/agent` repo:
+
+- stop hardcoding `thread_id = "default"`
+- derive thread identity from WebSocket connection context
+
+### Preferred mechanism
+
+Read `thread_id` from WebSocket query parameters rather than changing the message payload format.
+
+Example:
+
+`ws://host:port/agent_ws/?thread_id=matrix:@alice:example.org:C1`
+
+This is preferred because:
+
+- it limits the platform patch to one repo
+- it avoids changing both server and SDK protocol shape
+- it keeps the client message body text-only
+- it makes session identity explicit and easy to reason about
+
+## Why Not Use `platform/agent_api` Directly
+
+The bot should not depend on their client package for the prototype.
+
+Reasons:
+
+- the bot already has its own internal integration boundary in `sdk/interface.py`
+- a tiny local WebSocket client is enough for this protocol
+- avoiding a dependency on `platform/agent_api` keeps rebasing simpler
+- if upstream stabilizes later, the bot can adopt their SDK without affecting Matrix handlers
+
+## Repo Strategy
+
+### This repo
+
+Owns:
+
+- Matrix surface logic
+- SDK compatibility layer
+- local prototype state
+- backend selection and wiring
+
+### Forked `platform/agent`
+
+Owns only:
+
+- minimal thread identity patch required for per-chat memory
+
+### Explicitly not doing
+
+- no separate prototype repo
+- no changes to `platform/master` for v1
+- no unnecessary changes to `platform/agent_api`
+
+## Migration Path
+
+This design is intentionally expandable.
+
+When the platform develops further:
+
+- `sdk/prototype_state.py` can be replaced or reduced by a real `MasterClient`
+- `sdk/agent_session.py` can remain the direct session transport if still relevant
+- `RealPlatformClient` can continue to present the stable bot-facing interface
+- Telegram or another surface can reuse the same backend components without rethinking the integration model
+
+## Risks
+
+### Risk: hidden platform assumptions leak upward
+
+Mitigation:
+- keep all direct-agent logic below `RealPlatformClient`
+- avoid changing `core/` contracts for prototype convenience
+
+### Risk: settings semantics drift from future platform reality
+
+Mitigation:
+- make local settings behavior explicit in code and docs
+- keep settings isolated in `sdk/prototype_state.py`
+
+### Risk: upstream `agent` fork diverges
+
+Mitigation:
+- keep the patch minimal and narrowly scoped to thread identity
+
+### Risk: thread identity source is unstable
+
+Mitigation:
+- derive thread key from existing stable bot-side identities: platform, surface user id, logical chat id
+
+## Testing Strategy
+
+- Unit tests for `sdk/agent_session.py` request/response behavior
+- Unit tests for `sdk/prototype_state.py` local settings and user mapping
+- Unit tests for `sdk/real.py` contract compliance with `PlatformClient`
+- Matrix integration tests confirming:
+ - existing commands still work
+ - different logical chats map to different backend thread keys
+ - rename does not change thread identity
+ - archive stops reuse from the surface perspective
+
+## Success Criteria
+
+- Matrix can talk to the real agent without rewriting the Matrix adapter architecture
+- Chats do not share backend memory accidentally
+- Unsupported platform capabilities remain local or deferred rather than being faked as “real”
+- The backend boundary remains suitable for later Telegram or other surfaces
diff --git a/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md b/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md
new file mode 100644
index 0000000..9807bd6
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md
@@ -0,0 +1,278 @@
+# Matrix Per-Chat Context Design
+
+## Goal
+
+Move the Matrix surface from the current shared-agent-context MVP to true per-chat agent contexts using the new platform `chat_id`, while preserving the existing Matrix UX model built around rooms, spaces, and local chat labels such as `C1`, `C2`, and `C3`.
+
+## Core Decision
+
+The Matrix surface remains the owner of user-facing chat organization.
+
+- Matrix rooms, spaces, chat names, and archive state remain surface concerns.
+- The platform agent becomes the owner of actual conversation context.
+- The integration layer stores an explicit mapping from each surface chat to one platform context.
+
+This is the selected "Variant A" architecture:
+
+`surface_chat -> platform_chat_id`
+
+## Why This Decision
+
+The current Matrix adapter already has a stable UX model:
+
+- a user has a space
+- each working room has a local chat id like `C1`
+- commands such as `!new`, `!chats`, `!rename`, and `!archive` operate on that model
+
+Replacing that entire model with platform-native chat ids would be a broader refactor than needed. The surface and the platform solve different problems:
+
+- the surface organizes rooms and commands for users
+- the platform persists and branches real conversation context
+
+Keeping those responsibilities separate lets us add true per-chat context now without rebuilding the Matrix adapter around a new identity model.
+
+## Scope
+
+This design covers:
+
+- true per-chat context for Matrix rooms
+- a new `!branch` command
+- real context-aware semantics for `!new`, `!context`, `!save`, and `!load`
+- lazy migration of legacy Matrix rooms created before platform `chat_id` support
+
+This design does not cover:
+
+- end-to-end Matrix encryption support
+- Telegram changes
+- platform UI for browsing contexts
+- a future unified cross-surface chat browser
+
+## Data Model
+
+### Surface chat identity
+
+The Matrix surface keeps its existing identifiers:
+
+- Matrix room id, for example `!room:example.org`
+- local chat id, for example `C2`
+- room name
+- archive status
+- owning space id
+
+These remain the source of truth for Matrix UX.
+
+### Platform context identity
+
+Each working Matrix room gets a `platform_chat_id` stored in its room metadata.
+
+Example `room_meta` shape:
+
+```json
+{
+ "chat_id": "C2",
+ "space_id": "!space:example.org",
+ "name": "Research",
+ "platform_chat_id": "chat_8f2c..."
+}
+```
+
+Rules:
+
+- one working Matrix room maps to exactly one current platform context
+- two Matrix rooms must not share the same `platform_chat_id` unless we intentionally implement that later
+- branching creates a new `platform_chat_id`, never reuses the old one
+
+## Runtime Semantics
+
+### Normal message flow
+
+1. A Matrix message arrives in a working room.
+2. The Matrix adapter resolves the room to local `room_meta`.
+3. The integration layer reads `platform_chat_id` from that metadata.
+4. `RealPlatformClient.send_message(...)` sends the message to the platform using that `platform_chat_id`.
+5. The platform appends the exchange to that specific context and returns the reply.
+6. The Matrix adapter sends the reply back to the room.
+
+The key change is that the agent no longer treats all Matrix rooms as one shared context.
+
+### `!new`
+
+`!new` creates a new user-facing chat and a new empty platform context at the same time.
+
+Flow:
+
+1. Create a new Matrix room in the user space.
+2. Ask the platform to create a new blank context and return its `platform_chat_id`.
+3. Store that `platform_chat_id` in the new room metadata.
+4. Invite the user into the room.
+
+Result:
+
+- the new room is immediately independent
+- sending the first message does not share memory with the previous room
+
+### `!branch`
+
+`!branch` creates a new room whose starting point is a snapshot of the current room context.
+
+Flow:
+
+1. Resolve the current room's `platform_chat_id`.
+2. Ask the platform to create a new context branched from that source.
+3. Create a new Matrix room.
+4. Store the new `platform_chat_id` in the new room metadata.
+5. Invite the user into the new room.
+
+Result:
+
+- the new room starts with the current history and state
+- later messages diverge independently
+
+### `!save`
+
+`!save [name]` saves a snapshot of the current room's platform context under the current user.
+
+Semantics:
+
+- saves are owned by the user, not by the room
+- the saved snapshot originates from the current `platform_chat_id`
+
+### `!load`
+
+`!load` shows the user's saved contexts and loads the chosen snapshot into the current room's platform context.
+
+Semantics:
+
+- a saved context created in one room can be loaded into any other room owned by the same user
+- loading does not replace the Matrix room identity
+- loading affects only the current room's mapped `platform_chat_id`
+
+### `!context`
+
+`!context` reports the state of the current room context, not a global user session.
+
+Minimum expected output:
+
+- current room name or local chat id
+- current `platform_chat_id` presence or status
+- what saved context, if any, was last loaded here
+- last token usage if the platform still returns it
+
+## Legacy Room Migration
+
+Existing Matrix rooms were created before real platform `chat_id` support and therefore may not have `platform_chat_id` in their metadata.
+
+We need a non-destructive migration.
+
+### Lazy migration strategy
+
+For a room without `platform_chat_id`:
+
+1. On the first operation that requires platform context, detect the missing mapping.
+2. Create a new blank platform context for that room.
+3. Persist the new `platform_chat_id` into room metadata.
+4. Continue the requested operation normally.
+
+This applies to:
+
+- first normal message
+- `!context`
+- `!save`
+- `!load`
+- `!branch`
+
+This avoids forcing users to recreate their rooms manually.
+
+## Interface Changes
+
+### Matrix metadata
+
+Extend Matrix `room_meta` helpers to read and write `platform_chat_id`.
+
+### Real platform client
+
+`RealPlatformClient` must stop treating the current `chat_id` parameter as a purely local label when talking to the platform. The surface-facing call can still receive the local `chat_id`, but platform calls must use the resolved `platform_chat_id`.
+
+Recommended integration direction:
+
+- Matrix resolves the room mapping before calling the platform
+- `RealPlatformClient` receives the platform context id it should use
+
+This keeps the platform client simple and avoids giving it Matrix-specific storage responsibilities.
+
+### Agent API wrapper
+
+The wrapper must support platform calls that are explicitly context-aware:
+
+- create new context
+- branch context
+- send message into a specific context
+- save current context
+- load saved context into a specific context
+
+If upstream naming differs, the adapter layer should normalize those operations into stable local methods.
+
+## Command Semantics in MVP
+
+The MVP command set should evolve to this:
+
+- `!new` creates a new room with a new empty platform context
+- `!branch` creates a new room with a branched platform context
+- `!context` reports the current room context
+- `!save` saves the current room context for the user
+- `!load` loads one of the user's saved contexts into the current room
+
+Commands that do not have reliable backend support should remain hidden or explicitly marked unavailable.
+
+## Error Handling
+
+### Missing mapping
+
+If `platform_chat_id` is missing:
+
+- try lazy migration first
+- only return an error if migration fails
+
+### Platform create or branch failure
+
+If the platform cannot create or branch a context:
+
+- do not create partially-initialized room metadata
+- return a user-facing error in the source room
+- log enough detail to diagnose the backend failure
+
+### Save and load failure
+
+The surface must not claim success before the platform confirms success.
+
+For MVP quality:
+
+- user-facing text should say "request sent" only when confirmation is not available
+- once platform confirmation exists, switch to real success or failure messages
+
+## Testing
+
+Add or update tests for:
+
+- a new room gets a new `platform_chat_id`
+- two rooms created with `!new` do not share context ids
+- `!branch` creates a new room with a different `platform_chat_id` derived from the current one
+- sending messages from two rooms uses different platform context ids
+- saved contexts remain user-visible across rooms
+- loading the same saved context into two different rooms affects those rooms independently afterward
+- a legacy room without `platform_chat_id` lazily receives one on first use
+- failures during create, branch, save, and load do not leave broken metadata behind
+
+## Migration Path
+
+This design preserves a clean future direction:
+
+- Matrix continues to own its UX model
+- Telegram can adopt the same `surface_chat -> platform_chat_id` mapping later
+- when the platform matures further, more of the save/load/branch logic can move from prompts or local workarounds into real platform APIs
+
+The key long-term boundary stays stable:
+
+- surfaces own presentation and routing
+- the platform owns context
+- the integration layer owns the mapping
diff --git a/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md b/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
new file mode 100644
index 0000000..feca84c
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
@@ -0,0 +1,252 @@
+# Matrix Shared Workspace File Flow Design
+
+## Goal
+
+Bring the Matrix surface and `platform-agent` to a single file-handling model that matches the current platform runtime contract as closely as possible.
+
+The result should be:
+
+- Matrix receives user files and makes them visible to the agent through a shared `/workspace`
+- `platform-agent` receives attachment paths, not ad hoc summaries or inline payloads
+- the agent can send files back to the user through the surface via `send_file`
+- local development and the default deployment path use the same storage contract
+
+## Core Decision
+
+The selected architecture is:
+
+`Matrix surface <-> shared /workspace <-> platform-agent`
+
+This means:
+
+- the Matrix bot is responsible for downloading incoming Matrix media
+- downloaded files are written into the same filesystem mounted into `platform-agent`
+- the surface passes relative workspace paths to the agent as `attachments`
+- the agent returns files to the user by emitting `MsgEventSendFile(path=...)`
+
+This is the current platform-native direction and does not require new platform endpoints.
+
+## Why This Decision
+
+The current upstream platform changes already define the file contract:
+
+- `MsgUserMessage.attachments` is `list[str]`
+- each attachment is a path relative to `/workspace`
+- the agent validates those paths against its configured backend root
+- the agent can emit `send_file(path)` back to the client
+
+That is not an upload API and not a remote blob contract. It is an explicit shared-workspace contract.
+
+Trying to preserve the current separate-process launch model would force the surface to fake production behavior with inline text extraction, out-of-band path rewriting, or a future upload API that does not exist yet. That would increase the gap between our runtime and the platform runtime instead of reducing it.
+
+## Scope
+
+This design covers:
+
+- shared workspace runtime for Matrix bot and `platform-agent`
+- incoming Matrix file handling into shared storage
+- attachment path propagation to `RealPlatformClient` and `AgentApi`
+- outbound file delivery from agent to Matrix user
+- local compose/dev workflow and README updates
+
+This design does not cover:
+
+- Telegram file flow
+- encrypted Matrix media handling
+- upload APIs on the platform side
+- OCR, PDF parsing, or content extraction pipelines
+- long-term object storage or file lifecycle policies beyond basic cleanup boundaries
+
+## Runtime Contract
+
+### Shared filesystem
+
+Both containers must mount the same directory at `/workspace`.
+
+Requirements:
+
+- the Matrix bot can create files under `/workspace`
+- `platform-agent` sees the same files at the same relative paths
+- agent-originated files written under `/workspace` are readable by the Matrix bot
+
+The contract is path-based, not URL-based.
+
+### Attachment path format
+
+The surface sends attachments to the agent as relative workspace paths, for example:
+
+- `surfaces/matrix///inbox/20260420-153000-report.pdf`
+- `surfaces/matrix///inbox/20260420-153200-photo.jpg`
+
+Rules:
+
+- paths must be relative to `/workspace`
+- paths must be normalized before sending to the agent
+- surface-owned uploads must live under a dedicated namespace to avoid collisions with agent-created files
+
+## Data Flow
+
+### Incoming file from Matrix user
+
+1. Matrix receives `m.file`, `m.image`, `m.audio`, or `m.video`.
+2. The Matrix bot resolves the target room and platform chat context as usual.
+3. The Matrix bot downloads the media from Matrix.
+4. The file is stored under `/workspace/surfaces/matrix/.../inbox/...`.
+5. The outgoing platform call includes:
+ - original user text
+ - `attachments=[relative_path_1, ...]`
+6. `platform-agent` validates that those files exist and exposes them to the agent through the upstream attachment mechanism.
+
+Important detail:
+
+- the surface should not rewrite the user message into a synthetic file summary unless the message body is empty
+- when body is empty, the surface may send a minimal synthetic text such as `User sent one or more attachments.`
+
+### Outbound file from agent to Matrix user
+
+1. The agent uses `send_file(path)`.
+2. `platform-agent` emits `MsgEventSendFile(path=...)`.
+3. The Matrix integration catches that event.
+4. The Matrix bot resolves the file inside shared `/workspace`.
+5. The Matrix bot uploads the file to Matrix and sends the appropriate media message to the room.
+
+Surface behavior:
+
+- if MIME type and extension are known, send the closest native Matrix media type
+- otherwise send as `m.file`
+- user-visible failures must be explicit if the referenced file does not exist or cannot be uploaded
+
+## Filesystem Layout
+
+The Matrix surface owns a dedicated subtree:
+
+```text
+/workspace/
+ surfaces/
+ matrix/
+ /
+ /
+ inbox/
+ 20260420-153000-report.pdf
+```
+
+Design constraints:
+
+- sanitize user ids and room ids before using them as path components
+- preserve the original filename in the final basename where possible
+- prefix filenames with a timestamp or unique id to avoid collisions
+
+This layout is intentionally surface-scoped. The agent may read these files, but the surface remains the owner of how inbound messenger files are organized.
+
+## Components
+
+### Matrix attachment storage helper
+
+Add a focused helper module responsible for:
+
+- building stable workspace-relative paths
+- sanitizing path components
+- downloading Matrix media into `/workspace`
+- returning attachment metadata needed by the platform layer
+
+This helper should not know about agent transport details beyond the final relative path output.
+
+### Real platform client
+
+`RealPlatformClient` must pass attachment relative paths through to `AgentApi.send_message(...)`.
+
+It must also surface non-text agent events needed by the Matrix adapter, especially `MsgEventSendFile`.
+
+### Agent API wrapper
+
+`AgentApiWrapper` must be compatible with the modern upstream protocol:
+
+- `/v1/agent_ws/{chat_id}/`
+- `attachments` on outgoing user messages
+- `MsgEventToolCallChunk`
+- `MsgEventToolResult`
+- `MsgEventCustomUpdate`
+- `MsgEventSendFile`
+- `MsgEventEnd`
+
+### Matrix bot outbound renderer
+
+The Matrix adapter must support sending files back to the room.
+
+At minimum it needs:
+
+- path resolution inside shared workspace
+- Matrix upload of the local file
+- send of an `m.file` or native media event with filename and MIME type
+
+## Deployment Changes
+
+### Compose
+
+The repository root `docker-compose.yml` becomes the primary prod-like local runtime.
+
+It should define at least:
+
+- `matrix-bot`
+- `platform-agent`
+- one shared volume mounted as `/workspace` into both services
+
+The default developer workflow should stop describing `platform-agent` as a separately started side process.
+
+### Environment
+
+The Matrix bot must connect to the in-compose `platform-agent` service by service name, not by assuming a separately launched localhost process.
+
+The agent WebSocket configuration in docs and examples must match the modern upstream route.
+
+## Error Handling
+
+### Incoming files
+
+If the Matrix bot cannot download or persist the file:
+
+- do not send a broken attachment path to the agent
+- return a user-visible error in the room
+- log the Matrix event id, room id, and failure reason
+
+### Outbound files
+
+If the agent asks to send a missing file:
+
+- log a structured warning with the requested path
+- send a user-visible message that the file could not be delivered
+
+### Shared workspace mismatch
+
+If the runtime is misconfigured and `/workspace` is not actually shared:
+
+- inbound attachments will fail agent-side path validation
+- outbound `send_file` will fail surface-side file resolution
+
+The implementation should make such failures obvious in logs rather than silently degrading to text-only behavior.
+
+## Testing
+
+The implementation must cover:
+
+- Matrix media download writes into the expected workspace-relative path
+- `RealPlatformClient` forwards attachment relative paths to the agent API
+- Matrix plain messages with attachments preserve the original text while adding attachment paths
+- empty-body attachment-only messages produce the synthetic text fallback
+- `AgentApiWrapper` accepts `MsgEventSendFile` without treating it as unknown
+- Matrix outbound file handling converts `MsgEventSendFile` into a Matrix upload/send call
+- compose configuration mounts the same workspace into both containers
+
+## Non-Goals
+
+- no inline text extraction MVP
+- no temporary URL-passing contract to the agent
+- no fake “prod” mode with separate local filesystems
+- no platform API additions in this phase
+
+## Success Criteria
+
+- the default local runtime uses a shared `/workspace`
+- a user can send a file in Matrix and the agent receives it through upstream `attachments`
+- the agent can emit `send_file(path)` and the Matrix user receives the file in the same room
+- our runtime behavior matches the current platform contract closely enough that moving from local compose to production does not require redesigning file flow
diff --git a/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md b/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md
new file mode 100644
index 0000000..ae8a11a
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md
@@ -0,0 +1,262 @@
+# Matrix Staged Attachments Design
+
+## Goal
+
+Make file sending in the Matrix surface usable for an AI agent despite current Matrix client behavior, especially in Element where media is often sent immediately as separate events without a shared text composer.
+
+The result should be:
+
+- files can arrive before the user writes the actual instruction
+- the surface stages those files instead of immediately sending them to the agent
+- the next normal user message in the same chat commits all staged files as one agent turn
+- the user can inspect and remove staged files with short chat commands
+
+## Core Decision
+
+The selected UX model is:
+
+`incoming Matrix media -> staged attachments for (chat_id, user_id) -> next normal message commits them`
+
+This means:
+
+- attachment-only events do not immediately invoke the agent
+- the bot acknowledges staged files with a service message
+- the next normal user message sends text plus all currently staged files to the agent
+- staged files are then cleared
+
+## Why This Decision
+
+Matrix natively models messages as separate events, and common clients do not provide a reliable "one text message with many attachments" composer flow.
+
+In practice this causes two UX failures for an AI bot:
+
+- users may send files first and only then write the task
+- users may send multiple files as multiple independent Matrix events
+
+If the surface treats each incoming file as a full agent turn, the bot becomes noisy and context-fragmented. If it ignores file-only messages, file handling feels broken.
+
+Staging is the smallest surface-side abstraction that fixes both problems without fighting the Matrix event model.
+
+## Scope
+
+This design covers:
+
+- staging inbound Matrix attachments before agent submission
+- per-chat attachment state for a specific user
+- user-facing service messages for staged attachments
+- short commands for listing and removing staged files
+- commit behavior on the next normal message
+
+This design does not cover:
+
+- edits or redactions of original Matrix media events as attachment controls
+- cross-surface shared staging
+- thread-aware staging beyond the existing `chat_id` boundary
+- changes to the platform attachment contract
+
+## State Model
+
+### Staging key
+
+Staged attachments are isolated by:
+
+- `chat_id`
+- `user_id`
+
+This means:
+
+- files staged by a user in one chat never appear in another chat
+- files staged by one user do not mix with another user's files in the same room
+
+### Staged attachment record
+
+Each staged attachment must track at least:
+
+- stable internal id
+- display filename
+- workspace-relative path
+- MIME type if known
+- created timestamp
+
+User-visible commands operate on the current ordered list, not on internal ids.
+
+### Lifecycle
+
+A staged attachment is in exactly one of these states:
+
+1. `staged`
+2. `committed`
+3. `removed`
+
+Rules:
+
+- only `staged` attachments appear in `!list`
+- `committed` attachments are no longer user-removable
+- `removed` attachments are excluded from future commits
+
+## Inbound Behavior
+
+### Attachment-only event
+
+If the Matrix surface receives one or more file/media events from a user without a normal text message to commit them:
+
+1. download each file into shared `/workspace`
+2. add each file to the staged set for `(chat_id, user_id)`
+3. do not call the agent yet
+4. send a service acknowledgment message
+
+### Service acknowledgment
+
+The service message must communicate:
+
+- the current staged attachment list with indices
+- that the next normal message will be sent to the agent together with those files
+- available commands: `!list`, `!remove `, `!remove all`
+
+Example shape:
+
+```text
+Staged attachments:
+1. screenshot.png
+2. invoice.pdf
+
+Your next message will be sent to the agent with these files.
+Commands: !list, !remove , !remove all
+```
+
+### Burst handling
+
+Matrix clients may send multiple files as separate consecutive events.
+
+To avoid bot spam, service acknowledgments should be debounced over a short window and aggregated into one reply where feasible.
+
+The acknowledgment must reflect the full current staged set, not only the most recently received file.
+
+## Commit Behavior
+
+### Commit trigger
+
+The commit trigger is:
+
+- the next normal user message in the same `(chat_id, user_id)` scope
+
+Normal user message means:
+
+- not a staging control command
+- not a pure attachment event being staged
+
+### Commit action
+
+When a commit-triggering message arrives:
+
+1. collect all currently staged attachments for `(chat_id, user_id)`
+2. send the user text plus those attachments to the agent as one turn
+3. mark all included staged attachments as `committed`
+4. clear the staged set
+
+After commit:
+
+- the just-sent attachments must no longer appear in `!list`
+- a later file upload starts a new staged set
+
+## Commands
+
+### `!list`
+
+Shows the current staged attachment list for the user in the current chat.
+
+If the list is empty, the response should be short and explicit.
+
+### `!remove `
+
+Removes the staged attachment at the current 1-based index.
+
+Behavior:
+
+- if the index is valid, remove that staged attachment and return the updated staged list
+- if the index is invalid, return a short error without repeating the list
+
+### `!remove all`
+
+Clears the entire staged set for the user in the current chat.
+
+The response should be short and explicit.
+
+## Ordering Rules
+
+The staged list is ordered by staging time.
+
+User-facing indices:
+
+- are 1-based
+- are recalculated from the current staged set
+- may change after removals
+
+Therefore:
+
+- `!list` always shows the current authoritative numbering
+- after a successful `!remove `, the bot should reply with the refreshed list
+
+## Error Handling
+
+### Download failure
+
+If a file cannot be downloaded or stored:
+
+- do not add it to the staged set
+- do not pretend it will be sent later
+- send a short user-visible failure message
+
+### Invalid command
+
+If the command is malformed or uses an invalid index:
+
+- return a short error
+- do not commit staged attachments
+- do not clear the staged set
+
+### Agent submission failure
+
+If commit fails when sending the text plus staged files to the agent:
+
+- staged attachments must remain available for retry unless the failure is known to be irreversible
+- the user-visible error should make it clear that the files were not consumed
+
+This prevents silent loss of staged context.
+
+## Interaction with Shared Workspace Design
+
+This design assumes the shared-workspace contract defined in
+[2026-04-20-matrix-shared-workspace-file-flow-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md).
+
+Specifically:
+
+- staged files are stored in shared `/workspace`
+- the final commit still passes workspace-relative paths to `platform-agent`
+- staging changes only when the surface chooses to invoke the agent, not how attachments are represented
+
+## Testing
+
+The implementation must cover:
+
+- file-only Matrix events are staged and do not immediately invoke the agent
+- service acknowledgment includes staged filenames and command hints
+- `!list` returns the current staged set for the correct `(chat_id, user_id)`
+- `!remove ` removes the correct staged attachment and refreshes numbering
+- `!remove all` clears the staged set
+- invalid `!remove ` returns a short error and keeps state unchanged
+- the next normal message commits all staged attachments with the text as one agent turn
+- committed attachments disappear from staging after success
+- failed commits preserve staged attachments
+- staging in one chat does not leak into another chat
+- staging for one user does not leak to another user in the same room
+
+## Non-Goals
+
+This design intentionally does not attempt to:
+
+- emulate Telegram-style albums in Matrix
+- rely on special support from Element or other Matrix clients
+- introduce a rich interactive attachment management UI
+
+The goal is a reliable chat-native workflow that works within Matrix's actual event model.
diff --git a/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md b/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md
new file mode 100644
index 0000000..5fab5ef
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md
@@ -0,0 +1,318 @@
+# Transport Layer Thin Adapter Design
+
+## Цель
+
+Упростить transport layer между Matrix surface и `platform-agent` до максимально production-like вида:
+
+- использовать upstream `platform-agent_api.AgentApi` почти как есть
+- убрать из surface-side клиента собственную интерпретацию stream semantics
+- оставить в нашем коде только integration concerns:
+ - per-chat lifecycle
+ - per-chat serialization
+ - attachment path forwarding
+ - exception mapping в `PlatformError`
+
+Это нужно, чтобы:
+
+- восстановить чёткую границу ответственности между `surfaces` и платформой
+- убрать из диагностики ложные факторы, внесённые нашей кастомной обёрткой
+- получить честную картину реальных platform bugs до добавления любых policy-надстроек
+
+## Контекст
+
+Сейчас transport path состоит из:
+
+- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py)
+- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
+
+Изначально `AgentApiWrapper` был создан по разумным причинам:
+
+- поддержка переходного периода между разными версиями `platform-agent_api`
+- унификация `base_url/url`
+- создание per-chat client instances через `for_chat()`
+- локальный учёт `tokens_used`
+
+Позже в этот слой были добавлены уже не compatibility-функции, а собственные transport semantics:
+
+- custom `_listen()`
+- custom `send_message()`
+- post-END drain window
+- custom idle timeout
+- event-kind reclassification
+
+После этого `surfaces` перестал быть тонким клиентом платформы и начал вести себя как альтернативная реализация transport layer. Это делает диагностику platform bugs нечистой.
+
+## Принципы дизайна
+
+### 1. Transport должен быть скучным
+
+Transport layer не должен:
+
+- спасать поздние chunks
+- лечить duplicate `END`
+- придумывать собственные правила границы ответа
+- по-своему классифицировать stream events сверх upstream client behavior
+
+Если upstream stream protocol повреждён, мы должны видеть это как platform issue, а не скрывать его кастомной очередью.
+
+### 2. Policy и transport разделяются
+
+Transport:
+
+- говорит с upstream API
+- доставляет события
+- закрывает соединение
+
+Policy:
+
+- решает, что считать recoverable failure
+- нужна ли повторная попытка
+- как сообщать ошибку пользователю
+- нужно ли сбрасывать chat session
+
+На первом этапе policy не расширяется. Мы сначала приводим transport к тонкому адаптеру, потом заново оцениваем реальные проблемы.
+
+### 3. Session lifecycle остаётся на нашей стороне
+
+Даже в thin-adapter модели `surfaces` по-прежнему отвечает за:
+
+- кеширование client per chat
+- один send lock на chat
+- сброс мёртвой chat session после failure
+- mapping upstream exceptions в `PlatformError`
+
+Это не transport semantics, а integration lifecycle.
+
+## Варианты
+
+### Вариант A. Оставить текущий кастомный wrapper
+
+Плюсы:
+
+- уже работает на части сценариев
+- содержит built-in mitigations против observed failures
+
+Минусы:
+
+- нарушает границу ответственности
+- усложняет диагностику
+- делает platform bug reports спорными
+- содержит symptom-fix логику в transport layer
+
+Вердикт: не подходит как production-like target.
+
+### Вариант B. Thin upstream adapter
+
+Плюсы:
+
+- чистая архитектура
+- честная диагностика upstream проблем
+- минимальная собственная магия
+
+Минусы:
+
+- локальные mitigations исчезают
+- если upstream client несовершенен, это сразу проявится
+
+Вердикт: правильный первый этап.
+
+### Вариант C. Thin adapter сейчас, outer policy layer потом
+
+Плюсы:
+
+- даёт production-like эволюцию
+- не смешивает transport и resilience policy
+- позволяет сначала увидеть реальные проблемы, потом адресовать только нужные
+
+Минусы:
+
+- требует двух фаз вместо одной
+
+Вердикт: рекомендуемый путь.
+
+## Рекомендуемая архитектура
+
+### Слой 1. Upstream client
+
+Источник истины:
+
+- [external/platform-agent_api/lambda_agent_api/agent_api.py](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api/lambda_agent_api/agent_api.py)
+
+Мы принимаем его stream semantics как authoritative behavior.
+
+### Слой 2. Thin adapter
+
+Файл:
+
+- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py)
+
+После cleanup он должен содержать только:
+
+- создание клиента через modern constructor
+- `base_url` normalization, если это действительно нужно для наших env
+- `for_chat(chat_id)` как factory convenience
+- опционально thin storage для `last_tokens_used`, если это можно сделать без переопределения stream semantics
+
+Он не должен переопределять:
+
+- `_listen()`
+- `send_message()`
+- queue lifecycle
+- post-END behavior
+- timeout behavior
+
+### Слой 3. Integration/session layer
+
+Файл:
+
+- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
+
+Ответственность:
+
+- кешировать chat client instances
+- сериализовать sends по chat lock
+- вызывать `disconnect_chat(chat_id)` после transport failure
+- превращать upstream exceptions в `PlatformError`
+- форвардить `attachments` как relative workspace paths
+- собирать `MessageResponse` / `MessageChunk` для остального приложения
+
+Этот слой не должен заниматься:
+
+- исправлением broken stream boundaries
+- custom post-END reconstruction
+- поздним дренированием очереди
+
+## Что удаляем
+
+Из [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py):
+
+- custom `_listen()`
+- custom `send_message()`
+- `_drain_post_end_events()`
+- `_event_kind()`
+- `_is_kind()`
+- `_is_text_event()`
+- `_is_end_event()`
+- `_is_send_file_event()`
+- `_POST_END_DRAIN_MS`
+- `_STREAM_IDLE_TIMEOUT_MS`
+- debug logging, завязанное на наш собственный queue lifecycle
+
+## Что оставляем
+
+В thin adapter:
+
+- `__init__()` для modern `base_url/chat_id`
+- `_normalize_base_url()` только если нужен стабильный env input
+- `for_chat(chat_id)`
+
+В [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py):
+
+- `_get_chat_api()`
+- `_get_chat_send_lock()`
+- `_attachment_paths()`
+- `disconnect_chat()`
+- `_handle_chat_api_failure()`
+- `send_message()`
+- `stream_message()`
+
+## Дополнительное упрощение
+
+Если после cleanup мы считаем pinned upstream API обязательным, то из [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) можно убрать legacy-minded probing:
+
+- `inspect.signature(send_message)`
+- conditional fallback на старый `send_message(text)` без `attachments`
+
+В этом случае `RealPlatformClient` всегда использует современный контракт:
+
+- `send_message(text, attachments=...)`
+
+Это ещё сильнее уменьшит ambiguity.
+
+## Этапы миграции
+
+### Этап 1. Cleanup до thin adapter
+
+Делаем:
+
+- сжимаем `sdk/agent_api_wrapper.py` до thin shim
+- переносим всю допустимую resilience logic только в `sdk/real.py`
+- удаляем тесты, которые закрепляют наши кастомные transport semantics
+
+### Этап 2. Повторная верификация
+
+Заново прогоняем:
+
+- text-only flow
+- staged attachments flow
+- large image failure
+- duplicate `END` behavior
+- behavior after transport disconnect
+
+На этом этапе мы честно увидим, что реально делает upstream transport.
+
+### Этап 3. Опциональный outer policy layer
+
+Только если после Этапа 2 это действительно нужно, добавляем policy поверх transport:
+
+- request timeout целиком
+- retry policy
+- circuit-breaker-like behavior
+
+Но это должно жить не в client wrapper, а выше, в integration layer.
+
+## Тестовая стратегия
+
+### Удаляем как нецелевые тесты
+
+Больше не считаем нормой:
+
+- post-END drain behavior
+- recovery late chunks после `END`
+- idle timeout внутри wrapper как часть client contract
+
+### Оставляем и добавляем
+
+Нужные guarantees:
+
+1. создаётся отдельный client per chat
+2. один chat сериализуется через lock
+3. разные чаты не делят client instance
+4. attachment paths уходят в `send_message(..., attachments=...)`
+5. transport failure приводит к `disconnect_chat(chat_id)`
+6. следующий запрос после failure открывает новую chat session
+7. upstream exception превращается в `PlatformError`
+
+## Риски
+
+### 1. Может снова проявиться реальный upstream bug
+
+Это не regression дизайна, а полезный результат cleanup.
+
+### 2. Может исчезнуть локальная защита от зависших стримов
+
+Это допустимо на первом этапе.
+Если она действительно нужна, она должна вернуться как outer policy, а не как переписанный client transport.
+
+### 3. Может выясниться, что даже thin wrapper не нужен
+
+Если modern upstream `AgentApi` уже полностью покрывает наш use case, файл `sdk/agent_api_wrapper.py` можно будет заменить на маленький factory helper или убрать совсем.
+
+## Критерии успеха
+
+Результат считается успешным, если:
+
+- transport layer в `surfaces` перестаёт иметь собственную stream semantics
+- platform bug reports снова можно формулировать без сильного caveat про кастомный клиент
+- Matrix real backend продолжает работать на text-only и attachments scenarios
+- failure handling остаётся контролируемым, но больше не маскирует transport behavior платформы
+
+## Решение
+
+Принять путь:
+
+- `Thin upstream adapter now`
+- `Observe real behavior`
+- `Add outer policy later only if needed`
+
+Это наиболее близкий к production best practice вариант для текущего состояния проекта.
diff --git a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md
new file mode 100644
index 0000000..02cc89f
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md
@@ -0,0 +1,336 @@
+# Matrix Multi-Agent Routing Design
+
+## Goal
+
+Move the Matrix surface from a single hardcoded upstream agent to a user-selectable multi-agent model, while preserving the existing room-based UX and the current `PlatformClient` boundary.
+
+The result should be:
+
+- one Matrix bot can work with multiple upstream agents
+- users can choose an agent from the full configured list
+- each chat is bound to exactly one agent
+- switching the selected agent does not silently retarget an existing chat
+
+## Core Decision
+
+The selected routing model is:
+
+`user.selected_agent_id + room.agent_id + room.platform_chat_id`
+
+This means:
+
+- the user has one current selected agent
+- each Matrix working room stores the agent it is bound to
+- each Matrix working room stores its own `platform_chat_id`
+- a room never changes agent implicitly
+- the shared `PlatformClient` protocol remains unchanged
+- Matrix multi-agent routing is implemented by a single routing facade that delegates to per-agent real clients
+
+## Why This Decision
+
+The current Matrix adapter already separates:
+
+- user-facing room organization
+- local chat labels such as `C1`, `C2`, `C3`
+- platform-facing conversation identity via `platform_chat_id`
+
+Adding multi-agent support should preserve that shape instead of replacing it.
+
+If routing depended only on the current user selection, then an old room could start talking to a different agent after a switch. That would make room history and backend context hard to reason about. Binding an agent to the room keeps the conversation model explicit.
+
+## Scope
+
+This design covers:
+
+- agent selection by the user inside the Matrix surface
+- durable storage of the selected agent
+- durable storage of the room-bound agent
+- routing normal messages and context commands to the correct upstream agent
+- behavior when a room becomes stale after an agent switch
+
+This design does not cover:
+
+- per-agent workspace isolation
+- platform-side agent lifecycle or memory persistence
+- per-user allowlists for available agents
+- Telegram or other surfaces
+
+## Configuration Model
+
+### Agent registry
+
+Available agents are defined in a local config file loaded once at bot startup.
+
+Example:
+
+```yaml
+agents:
+ - id: agent-1
+ label: Analyst
+ - id: agent-2
+ label: Research
+ - id: agent-3
+ label: Ops
+```
+
+Rules:
+
+- every entry must have a stable `id`
+- every entry must have a user-visible `label`
+- all configured agents are selectable by all users
+- config changes apply only after bot restart
+
+### Startup validation
+
+If the agent config is missing, empty, or invalid, the Matrix bot must fail fast on startup with a clear operator error.
+
+## Durable State Model
+
+### User-level state
+
+User metadata keeps the current selected agent.
+
+Example `matrix_user:*` shape:
+
+```json
+{
+ "space_id": "!space:example.org",
+ "next_chat_index": 4,
+ "selected_agent_id": "agent-2"
+}
+```
+
+Meaning:
+
+- `selected_agent_id` controls future chat creation and activation of an unbound room
+- `selected_agent_id` does not rewrite already bound rooms
+
+### Room-level state
+
+Room metadata stores the agent bound to that chat.
+
+Example `matrix_room:*` shape:
+
+```json
+{
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Чат 3",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ "agent_id": "agent-2"
+}
+```
+
+Rules:
+
+- one room binds to exactly one `agent_id`
+- one room binds to exactly one current `platform_chat_id`
+- once a room becomes stale after an agent switch, it never becomes active again
+
+## Runtime Semantics
+
+### `!start`
+
+`!start` remains lightweight:
+
+- if no agent is selected, the bot explains that an agent must be selected before normal messaging
+- if an agent is already selected, the bot reports the current selection and reminds the user that `!new` creates a new room under that agent
+
+### `!agent`
+
+Introduce an agent-selection command.
+
+Behavior:
+
+- `!agent` shows the available agent list
+- agent selection stores `selected_agent_id` in user metadata
+- after a successful switch, the bot tells the user that existing chats bound to another agent are stale and that `!new` is required for continued work
+
+The exact UI can be text-first for MVP. A richer UI can be added later without changing the state model.
+
+### Normal message without selected agent
+
+If the user has not selected an agent yet:
+
+- do not call the platform
+- return the available agent list
+- ask the user to choose one first
+
+This is an intentional one-time routing handshake, not an accidental fallback.
+In a multi-agent deployment, the surface must not silently guess which agent an unbound user should talk to.
+
+### Selecting an agent inside an unbound chat
+
+If the current room has never been bound to any agent:
+
+- store the new `selected_agent_id` for the user
+- bind the current room to that same `agent_id`
+- allow the room to become the active working chat immediately
+
+This avoids forcing `!new` for the user's first usable chat.
+
+### `!new`
+
+`!new` creates a new working room under the current selected agent.
+
+Behavior:
+
+1. require `selected_agent_id`
+2. create the new Matrix room
+3. allocate a new `platform_chat_id`
+4. store `agent_id = selected_agent_id` in the new room metadata
+
+### Normal message in an unbound room with selected agent
+
+If a room exists but has no `agent_id` yet and the user already has `selected_agent_id`:
+
+- bind the room to `selected_agent_id`
+- ensure it has `platform_chat_id`
+- continue normal message dispatch
+
+### Normal message in a bound room
+
+If the room already has `agent_id` and it matches the current selected agent:
+
+- route the message to that `agent_id`
+- use the room's `platform_chat_id`
+
+### Stale room after agent switch
+
+If the room's bound `agent_id` differs from the user's current `selected_agent_id`:
+
+- do not call the platform
+- treat the room as stale
+- return a short message telling the user that this chat belongs to the old agent and that they must use `!new`
+
+### Returning to a previously selected agent
+
+If the user later selects an old agent again:
+
+- previously stale rooms do not become valid again
+- the user must still create a fresh room via `!new`
+
+## Routing and Component Changes
+
+### Agent registry loader
+
+Add a small loader responsible for:
+
+- reading `agents.yaml`
+- validating ids and labels
+- exposing a read-only registry to runtime code
+
+The runtime should not parse YAML ad hoc during message handling.
+
+### Matrix runtime pre-check
+
+Before dispatching a normal message, the Matrix runtime must resolve:
+
+- whether the user has `selected_agent_id`
+- whether the current room already has `agent_id`
+- whether the room can be bound now
+- whether the room is stale
+
+This pre-check happens before handing the message to the existing dispatcher path.
+
+### Routed platform client
+
+The selected implementation keeps the shared `PlatformClient` protocol unchanged.
+
+The Matrix runtime owns one routing-aware facade, for example `RoutedPlatformClient`, that implements `PlatformClient` and delegates to agent-specific real clients.
+
+Responsibilities:
+
+- resolve the current room binding from local Matrix metadata
+- translate a local Matrix logical chat id into the room's `platform_chat_id`
+- choose the correct per-agent delegate for the room's bound `agent_id`
+- keep `get_or_create_user`, `get_settings`, and `update_settings` behavior stable for the rest of the runtime
+
+This keeps the multi-agent logic inside the Matrix integration boundary instead of pushing agent selection into the shared protocol.
+
+### Real platform bridge delegates
+
+The current real backend path hardcodes a single runtime-level `agent_id`.
+That must be replaced with per-agent delegates hidden behind the routing facade.
+
+The selected design is:
+
+- `RealPlatformClient` remains the low-level direct-agent delegate for one configured `agent_id`
+- the routing facade holds or creates one `RealPlatformClient` delegate per configured agent
+- `send_message(...)` and `stream_message(...)` on the facade resolve the room target and forward the call to the matching delegate
+- the delegate creates a fresh upstream `AgentApi` for its configured `agent_id`
+- no long-lived `AgentApi` instances are cached by user
+
+This preserves the current fresh-connection-per-request behavior while avoiding a protocol break for Telegram or other surfaces.
+
+## Error Handling
+
+### Missing or invalid selected agent
+
+If `selected_agent_id` is absent:
+
+- ask the user to select an agent
+
+If `selected_agent_id` points to an agent that no longer exists in config:
+
+- treat the selection as invalid
+- ask the user to select again
+
+### Missing room binding
+
+If the room has no `agent_id`:
+
+- bind it only when the user has a valid current selection
+- otherwise return the selection prompt
+
+### Stale room
+
+If the room is stale:
+
+- do not attempt fallback routing
+- do not silently rewrite room metadata
+- instruct the user to run `!new`
+
+### Invalid config
+
+If the bot cannot load a valid agent registry:
+
+- fail at startup
+- do not start in degraded single-agent mode
+
+## Testing Expectations
+
+Tests for this design should prove:
+
+- config parsing and startup validation
+- selecting an agent persists `selected_agent_id`
+- selecting an agent inside an unbound room activates that room
+- `!new` binds the new room to the selected agent
+- messages in a bound room use that room's `agent_id`
+- stale rooms reject normal messaging with a clear `!new` instruction
+- returning to the same agent later does not revive stale rooms
+
+## Migration Notes
+
+Existing rooms may have `platform_chat_id` but no `agent_id`.
+
+For this MVP, treat those rooms as legacy-unbound rooms:
+
+- if the user has a valid selected agent, the room may be bound on first use
+- if no agent is selected, the room prompts for selection first
+
+No automatic migration across agents is introduced.
+
+### Existing users without `selected_agent_id`
+
+Existing users upgraded from the single-agent model may have working rooms but no stored `selected_agent_id`.
+
+For this MVP, that is handled explicitly:
+
+- normal messaging is paused until the user selects an agent
+- the first valid selection can bind an unbound room immediately
+- the surface does not auto-assign a default agent in a multi-agent config
+
+This is intentional. Once more than one agent exists, silent migration would be ambiguous and could route a user to the wrong backend target.
diff --git a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md
new file mode 100644
index 0000000..1f1cc7b
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md
@@ -0,0 +1,258 @@
+# Matrix Surface Restart State Persistence Design
+
+## Goal
+
+Make the Matrix surface survive a normal restart or container recreate without losing the minimal state required to keep working as a bot.
+
+The result should be:
+
+- after restart, the bot can still answer messages and execute commands
+- the bot remembers the selected agent for each user
+- the bot remembers which agent and `platform_chat_id` each room is bound to
+- temporary UX flows may be lost without being treated as a bug
+
+## Core Decision
+
+The selected persistence model is:
+
+`durable surface state only`
+
+This means:
+
+- persist only the state needed for routing and normal command handling
+- do not persist temporary UI and wizard state
+- require persistent local storage for the surface
+- do not attempt recovery if those volumes are lost
+
+## Why This Decision
+
+The Matrix surface already has two different classes of state:
+
+- stable local state that defines how rooms and users are routed
+- temporary UX state that exists only to complete short-lived interactions
+
+Trying to make all temporary UX state survive restart would add complexity and edge cases without improving the core requirement: the bot should still function normally after restart.
+
+The chosen design keeps persistence aligned with what the surface actually owns:
+
+- Matrix-side metadata and routing state are durable
+- agent conversation memory is the platform's responsibility
+- lost local volumes are treated as environment reset, not as an auto-recovery scenario
+
+## Scope
+
+This design covers:
+
+- which Matrix surface data must persist across restart
+- where that data lives
+- how restart behavior interacts with multi-agent routing
+- what state is intentionally non-durable
+
+This design does not cover:
+
+- platform-side persistence of agent memory
+- workspace isolation between multiple agents
+- automatic reconstruction after total local volume loss
+- persistence of temporary UX flows
+
+## Persistence Boundary
+
+### Durable state
+
+The Matrix surface must persist:
+
+- `matrix_user:*`
+- `matrix_room:*`
+- `chat:*`
+- `PLATFORM_CHAT_SEQ_KEY`
+- `selected_agent_id`
+- room-bound `agent_id`
+- room-bound `platform_chat_id`
+
+This is the minimal state required so that, after restart, the surface can:
+
+- identify the user
+- identify the room
+- determine which agent should receive a message
+- determine which `platform_chat_id` should be used
+- continue allocating new `platform_chat_id` values without reusing an already issued sequence number
+
+### Non-durable state
+
+The Matrix surface does not need to persist:
+
+- staged attachments
+- pending `!load` selection
+- pending `!yes/!no` confirmation
+- any temporary service UI step
+- live `AgentApi` instances or connection objects
+
+After restart, those flows may be lost. The bot only needs to remain operational.
+
+## Storage Model
+
+### Surface durable storage
+
+The Matrix surface must use persistent storage for:
+
+- `lambda_matrix.db`
+- `matrix_store`
+
+`lambda_matrix.db` stores the local key-value state used by the surface.
+`matrix_store` stores Matrix client state needed by `nio`.
+
+These paths must be backed by persistent container storage in normal deployments.
+
+### Shared `/workspace`
+
+The current local runtime also uses `/workspace`, but workspace behavior is outside the scope of this design.
+
+For this document, the only requirement is:
+
+- do not make restart persistence depend on solving per-agent workspace isolation first
+
+## Restart Assumptions
+
+This design assumes:
+
+- normal restart or redeploy with persistent local volumes still present
+
+This design does not assume:
+
+- automatic recovery after deleting or losing those volumes
+
+If the relevant volumes are lost, the environment is treated as reset.
+
+## Data Model Requirements
+
+### User metadata
+
+User metadata remains the durable location for user-level routing state.
+
+Example:
+
+```json
+{
+ "space_id": "!space:example.org",
+ "next_chat_index": 4,
+ "selected_agent_id": "agent-2"
+}
+```
+
+### Room metadata
+
+Room metadata remains the durable location for room-level routing state.
+
+Example:
+
+```json
+{
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Чат 3",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ "agent_id": "agent-2"
+}
+```
+
+### Platform chat sequence
+
+The global `PLATFORM_CHAT_SEQ_KEY` remains part of durable surface state.
+
+Its purpose is:
+
+- allocate monotonically increasing `platform_chat_id` values
+- avoid reusing a previously issued platform chat identifier during normal restart or redeploy
+
+This sequence must be stored in the same durable surface store as the room and user metadata.
+
+## Runtime Semantics After Restart
+
+After restart, the Matrix surface must:
+
+1. load the durable Matrix store
+2. load the durable surface key-value state
+3. load the agent registry config
+4. resume normal room routing using persisted `selected_agent_id`, `agent_id`, and `platform_chat_id`
+
+Expected behavior:
+
+- a user with a valid previously selected agent does not need to reselect it
+- a room previously bound to an agent remains bound to that agent
+- normal messages and commands continue to work
+
+### Lost temporary UX state
+
+If the bot restarts during a transient UX flow:
+
+- staged attachments may disappear
+- pending `!load` selections may disappear
+- pending confirmations may disappear
+
+This is acceptable and should not block normal operation after restart.
+
+## Interaction With Multi-Agent Routing
+
+The multi-agent design introduces new durable state that must survive restart:
+
+- `selected_agent_id` on the user
+- `agent_id` on the room
+- `PLATFORM_CHAT_SEQ_KEY` in the surface store
+
+Restart persistence and multi-agent routing therefore belong together.
+
+Without durable storage for those fields, a restart would make room routing ambiguous.
+
+## Failure Handling
+
+### Missing durable surface store
+
+If the durable store paths are missing because the environment was reset:
+
+- do not attempt to reconstruct a full working state from scratch in this design
+- treat startup as a clean environment
+- allow normal onboarding flows to begin again
+
+### Invalid durable references
+
+If persisted `selected_agent_id` or room `agent_id` references an agent no longer present in config:
+
+- do not crash
+- treat the selection or room binding as invalid
+- ask the user to select a valid agent again
+
+### Platform conversation memory
+
+If the upstream platform loses agent memory across restart:
+
+- that is outside the surface persistence boundary
+- the surface must still route correctly
+- platform memory persistence remains a platform responsibility
+
+## Testing Expectations
+
+Tests for this design should prove:
+
+- `selected_agent_id` survives restart through durable local storage
+- room `agent_id` and `platform_chat_id` survive restart through durable local storage
+- the bot can route messages correctly after restart without user reconfiguration
+- missing temporary UX state does not break normal messaging and command handling
+- invalid persisted agent references degrade into reselection prompts rather than crashes
+
+## Operational Notes
+
+For the Matrix surface to survive restart in the intended way, deployment must persist:
+
+- `lambda_matrix.db`
+- `matrix_store`
+
+This is a deployment requirement, not an optional optimization.
+
+The design intentionally stops there. It does not require:
+
+- hot reload of agent config
+- recovery after total local state loss
+- persistence of temporary UX flows
+- a solved multi-agent workspace story
diff --git a/pyproject.toml b/pyproject.toml
index 8f4978b..73dfbd7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,12 +15,15 @@ dependencies = [
"structlog>=24.1",
"python-dotenv>=1.0",
"httpx>=0.27",
+ "aiohttp>=3.9",
+ "pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
+ "pytest-aiohttp>=1.0",
"pytest-cov>=4.1",
"ruff>=0.3",
"mypy>=1.8",
diff --git a/sdk/__init__.py b/sdk/__init__.py
index e69de29..f7939f7 100644
--- a/sdk/__init__.py
+++ b/sdk/__init__.py
@@ -0,0 +1,9 @@
+__all__ = ["RealPlatformClient"]
+
+
+def __getattr__(name: str):
+ if name == "RealPlatformClient":
+ from sdk.real import RealPlatformClient
+
+ return RealPlatformClient
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/sdk/agent_session.py b/sdk/agent_session.py
new file mode 100644
index 0000000..187b88a
--- /dev/null
+++ b/sdk/agent_session.py
@@ -0,0 +1 @@
+"""Compatibility stub: AgentSessionClient was replaced by direct AgentApi usage in Phase 4."""
diff --git a/sdk/interface.py b/sdk/interface.py
index e1ff12e..7b43b1b 100644
--- a/sdk/interface.py
+++ b/sdk/interface.py
@@ -1,10 +1,11 @@
# platform/interface.py
from __future__ import annotations
+from collections.abc import AsyncIterator
from datetime import datetime
-from typing import Any, AsyncIterator, Literal, Protocol
+from typing import Any, Literal, Protocol
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class User(BaseModel):
@@ -17,10 +18,11 @@ class User(BaseModel):
class Attachment(BaseModel):
- url: str
- mime_type: str
+ url: str | None = None
+ mime_type: str | None = None
size: int | None = None
filename: str | None = None
+ workspace_path: str | None = None
class MessageResponse(BaseModel):
@@ -28,10 +30,12 @@ class MessageResponse(BaseModel):
response: str
tokens_used: int
finished: bool
+ attachments: list[Attachment] = Field(default_factory=list)
class MessageChunk(BaseModel):
"""Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True."""
+
message_id: str
delta: str
finished: bool
@@ -48,6 +52,7 @@ class UserSettings(BaseModel):
class AgentEvent(BaseModel):
"""Webhook-уведомление от платформы — агент закончил долгую задачу."""
+
event_id: str
user_id: str
chat_id: str
@@ -94,4 +99,5 @@ class PlatformClient(Protocol):
class WebhookReceiver(Protocol):
"""Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу."""
+
async def on_agent_event(self, event: AgentEvent) -> None: ...
diff --git a/sdk/mock.py b/sdk/mock.py
index 622d0d3..06e49ac 100644
--- a/sdk/mock.py
+++ b/sdk/mock.py
@@ -4,8 +4,9 @@ from __future__ import annotations
import asyncio
import random
import uuid
+from collections.abc import AsyncIterator
from datetime import UTC, datetime
-from typing import Any, AsyncIterator, Literal
+from typing import Any, Literal
import structlog
@@ -222,14 +223,16 @@ class MockPlatformClient:
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
tokens = len(text.split()) * 2
- self._messages[key].append({
- "message_id": message_id,
- "user_text": text,
- "response": response,
- "tokens_used": tokens,
- "finished": True,
- "created_at": datetime.now(UTC).isoformat(),
- })
+ self._messages[key].append(
+ {
+ "message_id": message_id,
+ "user_text": text,
+ "response": response,
+ "tokens_used": tokens,
+ "finished": True,
+ "created_at": datetime.now(UTC).isoformat(),
+ }
+ )
return message_id, response, tokens
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
diff --git a/sdk/prototype_state.py b/sdk/prototype_state.py
new file mode 100644
index 0000000..6e5fd41
--- /dev/null
+++ b/sdk/prototype_state.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from sdk.interface import User, UserSettings
+
+# Keep the prototype backend self-contained; do not import these from sdk.mock.
+DEFAULT_SKILLS: dict[str, bool] = {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+}
+DEFAULT_SAFETY: dict[str, bool] = {
+ "email-send": True,
+ "file-delete": True,
+ "social-post": True,
+}
+DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""}
+DEFAULT_PLAN: dict[str, Any] = {
+ "name": "Beta",
+ "tokens_used": 0,
+ "tokens_limit": 1000,
+}
+
+
+class PrototypeStateStore:
+ def __init__(self) -> None:
+ self._users: dict[str, User] = {}
+ self._settings: dict[str, dict[str, Any]] = {}
+ self._saved_sessions: dict[str, list[dict[str, str]]] = {}
+ self._context_last_tokens_used: dict[str, int] = {}
+ self._context_current_session: dict[str, str] = {}
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ key = f"{platform}:{external_id}"
+ existing = self._users.get(key)
+ if existing is not None:
+ stored = existing.model_copy(update={"is_new": False})
+ self._users[key] = stored
+ return stored.model_copy()
+
+ user = User(
+ user_id=f"usr-{platform}-{external_id}",
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ created_at=datetime.now(UTC),
+ is_new=True,
+ )
+ self._users[key] = user.model_copy(update={"is_new": False})
+ return user.model_copy()
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ stored = self._settings.get(user_id, {})
+ return UserSettings(
+ skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
+ connectors=dict(stored.get("connectors", {})),
+ soul={**DEFAULT_SOUL, **stored.get("soul", {})},
+ safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
+ plan={**DEFAULT_PLAN, **stored.get("plan", {})},
+ )
+
+ async def update_settings(self, user_id: str, action: Any) -> None:
+ settings = self._settings.setdefault(user_id, {})
+
+ if action.action == "toggle_skill":
+ skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
+ skills[action.payload["skill"]] = action.payload.get("enabled", True)
+ elif action.action == "set_soul":
+ soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
+ soul[action.payload["field"]] = action.payload["value"]
+ elif action.action == "set_safety":
+ safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
+ safety[action.payload["trigger"]] = action.payload.get("enabled", True)
+
+ async def add_saved_session(
+ self,
+ user_id: str,
+ name: str,
+ *,
+ source_context_id: str | None = None,
+ ) -> None:
+ sessions = self._saved_sessions.setdefault(user_id, [])
+ session = {"name": name, "created_at": datetime.now(UTC).isoformat()}
+ if source_context_id is not None:
+ session["source_context_id"] = source_context_id
+ sessions.append(session)
+
+ async def list_saved_sessions(self, user_id: str) -> list[dict[str, str]]:
+ return [dict(session) for session in self._saved_sessions.get(user_id, [])]
+
+ async def get_last_tokens_used_for_context(self, context_id: str) -> int:
+ return self._context_last_tokens_used.get(context_id, 0)
+
+ async def set_last_tokens_used_for_context(self, context_id: str, tokens: int) -> None:
+ self._context_last_tokens_used[context_id] = tokens
+
+ async def get_current_session_for_context(self, context_id: str) -> str | None:
+ return self._context_current_session.get(context_id)
+
+ async def set_current_session_for_context(self, context_id: str, name: str) -> None:
+ self._context_current_session[context_id] = name
+
+ async def clear_current_session_for_context(self, context_id: str) -> None:
+ self._context_current_session.pop(context_id, None)
+
+ async def get_last_tokens_used(self, context_id: str) -> int:
+ return await self.get_last_tokens_used_for_context(context_id)
+
+ async def set_last_tokens_used(self, context_id: str, tokens: int) -> None:
+ await self.set_last_tokens_used_for_context(context_id, tokens)
+
+ async def get_current_session(self, context_id: str) -> str | None:
+ return await self.get_current_session_for_context(context_id)
+
+ async def set_current_session(self, context_id: str, name: str) -> None:
+ await self.set_current_session_for_context(context_id, name)
+
+ async def clear_current_session(self, context_id: str) -> None:
+ await self.clear_current_session_for_context(context_id)
diff --git a/sdk/real.py b/sdk/real.py
new file mode 100644
index 0000000..47f639a
--- /dev/null
+++ b/sdk/real.py
@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+from collections.abc import AsyncIterator
+from pathlib import Path
+from urllib.parse import urljoin, urlsplit, urlunsplit
+
+import structlog
+
+from sdk.interface import (
+ Attachment,
+ MessageChunk,
+ MessageResponse,
+ PlatformClient,
+ PlatformError,
+ User,
+ UserSettings,
+)
+from sdk.prototype_state import PrototypeStateStore
+from sdk.upstream_agent_api import AgentApi, MsgEventSendFile, MsgEventTextChunk
+
+logger = structlog.get_logger(__name__)
+
+
+def _ws_debug_enabled() -> bool:
+ value = os.environ.get("SURFACES_DEBUG_WS", "")
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+class RealPlatformClient(PlatformClient):
+ def __init__(
+ self,
+ agent_id: str,
+ agent_base_url: str,
+ prototype_state: PrototypeStateStore,
+ platform: str = "matrix",
+ agent_api_cls=AgentApi,
+ ) -> None:
+ self._agent_id = agent_id
+ self._raw_agent_base_url = agent_base_url
+ self._agent_base_url = self._normalize_agent_base_url(agent_base_url)
+ self._agent_api_cls = agent_api_cls
+ self._prototype_state = prototype_state
+ self._platform = platform
+ self._chat_send_locks: dict[str, asyncio.Lock] = {}
+ if _ws_debug_enabled():
+ logger.warning(
+ "agent_client_initialized",
+ agent_id=self._agent_id,
+ platform=self._platform,
+ raw_base_url=self._raw_agent_base_url,
+ normalized_base_url=self._agent_base_url,
+ )
+
+ @property
+ def agent_id(self) -> str:
+ return self._agent_id
+
+ @property
+ def agent_base_url(self) -> str:
+ return self._agent_base_url
+
+ def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock:
+ chat_key = str(chat_id)
+ lock = self._chat_send_locks.get(chat_key)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._chat_send_locks[chat_key] = lock
+ return lock
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ return await self._prototype_state.get_or_create_user(
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> MessageResponse:
+ response_parts: list[str] = []
+ sent_attachments: list[Attachment] = []
+ message_id = user_id
+
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = self._build_chat_api(chat_id)
+ try:
+ await chat_api.connect()
+ async for event in self._stream_agent_events(
+ chat_api, text, attachments=attachments
+ ):
+ message_id = user_id
+ if isinstance(event, MsgEventTextChunk) and event.text:
+ response_parts.append(event.text)
+ elif isinstance(event, MsgEventSendFile):
+ attachment = self._attachment_from_send_file_event(event)
+ if attachment is not None:
+ sent_attachments.append(attachment)
+ except Exception as exc:
+ raise self._to_platform_error(exc) from exc
+ finally:
+ await self._close_chat_api(chat_api)
+ await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
+
+ response_kwargs = {
+ "message_id": message_id,
+ "response": "".join(response_parts),
+ "tokens_used": 0,
+ "finished": True,
+ "attachments": sent_attachments,
+ }
+ return MessageResponse(**response_kwargs)
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[MessageChunk]:
+ lock = self._get_chat_send_lock(chat_id)
+ async with lock:
+ chat_api = self._build_chat_api(chat_id)
+ try:
+ await chat_api.connect()
+ async for event in self._stream_agent_events(
+ chat_api, text, attachments=attachments
+ ):
+ if isinstance(event, MsgEventTextChunk):
+ yield MessageChunk(
+ message_id=user_id,
+ delta=event.text,
+ finished=False,
+ )
+ elif isinstance(event, MsgEventSendFile):
+ continue
+ except Exception as exc:
+ raise self._to_platform_error(exc) from exc
+ finally:
+ await self._close_chat_api(chat_api)
+ await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
+ yield MessageChunk(
+ message_id=user_id,
+ delta="",
+ finished=True,
+ tokens_used=0,
+ )
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ return await self._prototype_state.get_settings(user_id)
+
+ async def update_settings(self, user_id: str, action) -> None:
+ await self._prototype_state.update_settings(user_id, action)
+
+ async def disconnect_chat(self, chat_id: str) -> None:
+ self._chat_send_locks.pop(str(chat_id), None)
+
+ async def close(self) -> None:
+ self._chat_send_locks.clear()
+
+ async def _stream_agent_events(
+ self,
+ chat_api,
+ text: str,
+ attachments: list[Attachment] | None = None,
+ ) -> AsyncIterator[object]:
+ attachment_paths = self._attachment_paths(attachments)
+ event_stream = chat_api.send_message(text, attachments=attachment_paths or None)
+ chunk_index = 0
+ async for event in event_stream:
+ if isinstance(event, MsgEventTextChunk):
+ logger.debug("agent_chunk", index=chunk_index, text=repr(event.text[:40]))
+ chunk_index += 1
+ else:
+ logger.debug("agent_event", index=chunk_index, type=type(event).__name__)
+ yield event
+
+ def _build_chat_api(self, chat_id: str):
+ if _ws_debug_enabled():
+ logger.warning(
+ "agent_chat_api_build",
+ agent_id=self._agent_id,
+ chat_id=str(chat_id),
+ normalized_base_url=self._agent_base_url,
+ ws_url=urljoin(self._agent_base_url, f"v1/agent_ws/{chat_id}/"),
+ )
+ return self._agent_api_cls(
+ agent_id=self._agent_id,
+ base_url=self._agent_base_url,
+ chat_id=str(chat_id),
+ )
+
+ @staticmethod
+ def _normalize_agent_base_url(base_url: str) -> str:
+ parsed = urlsplit(base_url)
+ path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
+ if path:
+ path = f"{path}/"
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
+
+ @staticmethod
+ async def _close_chat_api(chat_api) -> None:
+ close = getattr(chat_api, "close", None)
+ if callable(close):
+ try:
+ await close()
+ except Exception:
+ pass
+
+ @staticmethod
+ def _to_platform_error(exc: Exception) -> PlatformError:
+ code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
+ return PlatformError(str(exc), code=code)
+
+ @staticmethod
+ def _normalize_workspace_path(location: str) -> str | None:
+ if not location:
+ return None
+
+ path = Path(location)
+ if not path.is_absolute():
+ normalized = path.as_posix()
+ return normalized or None
+
+ parts = path.parts
+ if len(parts) >= 2 and parts[1] == "workspace":
+ relative = Path(*parts[2:]).as_posix()
+ return relative or None
+ if len(parts) >= 3 and parts[1] == "agents":
+ relative = Path(*parts[3:]).as_posix()
+ return relative or None
+
+ relative = path.as_posix().lstrip("/")
+ return relative or None
+
+ @staticmethod
+ def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
+ if not attachments:
+ return []
+ paths = []
+ for attachment in attachments:
+ if attachment.workspace_path:
+ normalized = RealPlatformClient._normalize_workspace_path(
+ attachment.workspace_path
+ )
+ if normalized:
+ paths.append(normalized)
+ return paths
+
+ @staticmethod
+ def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment:
+ location = str(event.path)
+ filename = Path(location).name or None
+ workspace_path = RealPlatformClient._normalize_workspace_path(location)
+ return Attachment(
+ url=location,
+ mime_type="application/octet-stream",
+ size=None,
+ filename=filename,
+ workspace_path=workspace_path or None,
+ )
diff --git a/sdk/upstream_agent_api.py b/sdk/upstream_agent_api.py
new file mode 100644
index 0000000..d0bfdd7
--- /dev/null
+++ b/sdk/upstream_agent_api.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
+if str(_api_root) not in sys.path:
+ sys.path.insert(0, str(_api_root))
+
+from lambda_agent_api.agent_api import AgentApi, AgentBusyException, AgentException # noqa: E402
+from lambda_agent_api.server import MsgEventSendFile, MsgEventTextChunk # noqa: E402
+
+__all__ = [
+ "AgentApi",
+ "AgentBusyException",
+ "AgentException",
+ "MsgEventSendFile",
+ "MsgEventTextChunk",
+]
diff --git a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg
new file mode 100644
index 0000000..af4606d
Binary files /dev/null and b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg differ
diff --git a/tests/adapter/matrix/test_agent_registry.py b/tests/adapter/matrix/test_agent_registry.py
new file mode 100644
index 0000000..a918f84
--- /dev/null
+++ b/tests/adapter/matrix/test_agent_registry.py
@@ -0,0 +1,199 @@
+from pathlib import Path
+
+import pytest
+
+from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
+
+
+def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n",
+ encoding="utf-8",
+ )
+
+ registry = load_agent_registry(path)
+
+ assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
+ assert registry.get("agent-1").label == "Analyst"
+
+
+def test_agent_registry_agents_sequence_is_immutable(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n",
+ encoding="utf-8",
+ )
+
+ registry = load_agent_registry(path)
+
+ with pytest.raises(AttributeError):
+ registry.agents.append( # type: ignore[attr-defined]
+ registry.agents[0]
+ )
+
+
+def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-1\n"
+ " label: Duplicate\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="duplicate agent id"):
+ load_agent_registry(path)
+
+
+def test_load_agent_registry_rejects_non_mapping_entries(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - agent-1\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "",
+ "agents: []\n",
+ "agents: agent-1\n",
+ "foo: bar\n",
+ ],
+)
+def test_load_agent_registry_rejects_missing_non_list_and_empty_agents(
+ tmp_path: Path, content: str
+):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="agents registry must contain a non-empty agents list"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content, expected",
+ [
+ (
+ "agents:\n"
+ " - label: Analyst\n",
+ "each agent entry requires id and label",
+ ),
+ (
+ "agents:\n"
+ " - id: agent-1\n",
+ "each agent entry requires id and label",
+ ),
+ ],
+)
+def test_load_agent_registry_rejects_missing_id_or_label(tmp_path: Path, content: str, expected: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match=expected):
+ load_agent_registry(path)
+
+
+def test_load_agent_registry_rejects_invalid_top_level_yaml(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "- id: agent-1\n"
+ " label: Analyst\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="agent registry must be a mapping with an agents list"):
+ load_agent_registry(path)
+
+
+def test_load_agent_registry_rejects_malformed_yaml(tmp_path: Path):
+ path = tmp_path / "agents.yaml"
+ path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n"
+ " - [\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(AgentRegistryError, match="invalid agent registry YAML"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "agents:\n"
+ " - id: null\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: null\n",
+ ],
+)
+def test_load_agent_registry_rejects_null_id_or_label(tmp_path: Path, content: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "agents:\n"
+ " - id: ' '\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: ' '\n",
+ ],
+)
+def test_load_agent_registry_rejects_blank_id_or_label(tmp_path: Path, content: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
+
+
+@pytest.mark.parametrize(
+ "content",
+ [
+ "agents:\n"
+ " - id: 123\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: 456\n",
+ "agents:\n"
+ " - id: true\n"
+ " label: Analyst\n",
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: false\n",
+ ],
+)
+def test_load_agent_registry_rejects_non_string_id_or_label(tmp_path: Path, content: str):
+ path = tmp_path / "agents.yaml"
+ path.write_text(content, encoding="utf-8")
+
+ with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
+ load_agent_registry(path)
diff --git a/tests/adapter/matrix/test_chat_space.py b/tests/adapter/matrix/test_chat_space.py
index 91ee27a..e33fb98 100644
--- a/tests/adapter/matrix/test_chat_space.py
+++ b/tests/adapter/matrix/test_chat_space.py
@@ -6,8 +6,12 @@ from unittest.mock import AsyncMock
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
-from adapter.matrix.handlers.chat import make_handle_archive, make_handle_new_chat, make_handle_rename
-from adapter.matrix.store import set_user_meta
+from adapter.matrix.handlers.chat import (
+ make_handle_archive,
+ make_handle_new_chat,
+ make_handle_rename,
+)
+from adapter.matrix.store import get_room_meta, set_user_meta
from core.auth import AuthManager
from core.chat import ChatManager
from core.protocol import IncomingCommand, OutgoingMessage
@@ -28,7 +32,9 @@ async def _setup():
async def test_mat04_new_chat_calls_room_put_state_with_space_id():
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})
+ 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")),
@@ -57,6 +63,9 @@ async def test_mat04_new_chat_calls_room_put_state_with_space_id():
assert kwargs.get("room_id") == "!space:ex"
assert kwargs.get("event_type") == "m.space.child"
assert kwargs.get("state_key") == "!newroom:ex"
+ room_meta = await get_room_meta(store, "!newroom:ex")
+ assert room_meta is not None
+ assert room_meta["platform_chat_id"] == "1"
assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result)
@@ -166,10 +175,14 @@ async def test_mat11b_rename_from_unregistered_room_returns_error_message():
async def test_mat12_room_create_error_returns_user_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})
+ await set_user_meta(
+ store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}
+ )
client = SimpleNamespace(
- room_create=AsyncMock(return_value=RoomCreateError(message="rate limited", status_code="429")),
+ room_create=AsyncMock(
+ return_value=RoomCreateError(message="rate limited", status_code="429")
+ ),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
diff --git a/tests/adapter/matrix/test_context_commands.py b/tests/adapter/matrix/test_context_commands.py
new file mode 100644
index 0000000..9264a06
--- /dev/null
+++ b/tests/adapter/matrix/test_context_commands.py
@@ -0,0 +1,350 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from adapter.matrix.bot import MatrixBot, build_runtime
+from adapter.matrix.handlers import register_matrix_handlers
+from adapter.matrix.handlers.context_commands import (
+ make_handle_context,
+ make_handle_load,
+ make_handle_reset,
+ make_handle_save,
+)
+from adapter.matrix.store import (
+ get_load_pending,
+ set_load_pending,
+ set_room_meta,
+)
+from core.protocol import IncomingCommand, OutgoingMessage
+from core.store import InMemoryStore
+from sdk.interface import MessageResponse
+from sdk.mock import MockPlatformClient
+from sdk.prototype_state import PrototypeStateStore
+
+
+class MatrixCommandPlatform(MockPlatformClient):
+ def __init__(self) -> None:
+ super().__init__()
+ self._prototype_state = PrototypeStateStore()
+ self._agent_api = object()
+ self.disconnect_chat = AsyncMock()
+ self.send_message = AsyncMock(
+ return_value=MessageResponse(
+ message_id="msg-1",
+ response="ok",
+ tokens_used=0,
+ finished=True,
+ )
+ )
+
+
+@pytest.fixture(autouse=True)
+def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
+ monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False)
+
+
+@pytest.mark.asyncio
+async def test_save_command_auto_name_records_session():
+ platform = MatrixCommandPlatform()
+ store = InMemoryStore()
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ handler = make_handle_save(
+ agent_api=platform._agent_api,
+ store=store,
+ prototype_state=platform._prototype_state,
+ )
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="!room:example.org",
+ command="save",
+ args=[],
+ )
+
+ result = await handler(event, None, platform, None, None)
+
+ assert len(result) == 1
+ assert isinstance(result[0], OutgoingMessage)
+ assert "Запрос на сохранение отправлен агенту" in result[0].text
+ sessions = await platform._prototype_state.list_saved_sessions("u1")
+ assert len(sessions) == 1
+ assert sessions[0]["name"].startswith("context-")
+ assert sessions[0]["source_context_id"] == "41"
+
+
+@pytest.mark.asyncio
+async def test_save_command_with_name_uses_given_name():
+ platform = MatrixCommandPlatform()
+ store = InMemoryStore()
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ handler = make_handle_save(
+ agent_api=platform._agent_api,
+ store=store,
+ prototype_state=platform._prototype_state,
+ )
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="!room:example.org",
+ command="save",
+ args=["my-session"],
+ )
+
+ await handler(event, None, platform, None, None)
+
+ sessions = await platform._prototype_state.list_saved_sessions("u1")
+ assert [session["name"] for session in sessions] == ["my-session"]
+
+
+@pytest.mark.asyncio
+async def test_load_command_shows_numbered_list_and_sets_pending():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room:example.org",
+ name="Chat 1",
+ )
+ await platform._prototype_state.add_saved_session("u1", "session-a")
+ await platform._prototype_state.add_saved_session("u1", "session-b")
+
+ handler = make_handle_load(store=runtime.store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ assert "1. session-a" in result[0].text
+ assert "2. session-b" in result[0].text
+ pending = await get_load_pending(runtime.store, "u1", "!room:example.org")
+ assert pending is not None
+ assert [session["name"] for session in pending["saves"]] == ["session-a", "session-b"]
+
+
+@pytest.mark.asyncio
+async def test_load_command_without_saved_sessions_reports_empty():
+ platform = MatrixCommandPlatform()
+ store = InMemoryStore()
+ handler = make_handle_load(store=store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
+
+ result = await handler(event, None, platform, None, None)
+
+ assert "Нет сохранённых сессий" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_reset_command_assigns_new_platform_chat_id():
+ from adapter.matrix.store import get_platform_chat_id, set_room_meta
+ from sdk.prototype_state import PrototypeStateStore
+
+ prototype_state = PrototypeStateStore()
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ store = runtime.store
+
+ await set_room_meta(store, "!room:example.org", {"platform_chat_id": "7"})
+
+ handler = make_handle_reset(store=store, prototype_state=prototype_state)
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="!room:example.org",
+ command="reset",
+ args=[],
+ )
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ new_id = await get_platform_chat_id(store, "!room:example.org")
+ assert new_id != "7"
+ assert new_id == "1"
+ assert "сброшен" in result[0].text.lower()
+
+
+@pytest.mark.asyncio
+async def test_clear_command_rotates_only_current_room_and_disconnects_old_upstream_chat():
+ from adapter.matrix.store import get_platform_chat_id
+
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room-a:example.org",
+ name="Chat A",
+ )
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C2",
+ platform="matrix",
+ surface_ref="!room-b:example.org",
+ name="Chat B",
+ )
+ await set_room_meta(
+ runtime.store,
+ "!room-a:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!room-b:example.org",
+ {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "99"},
+ )
+
+ handler = make_handle_reset(store=runtime.store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ command="clear",
+ args=[],
+ )
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ room_a_chat_id = await get_platform_chat_id(runtime.store, "!room-a:example.org")
+ room_b_chat_id = await get_platform_chat_id(runtime.store, "!room-b:example.org")
+ assert room_a_chat_id == "1"
+ assert room_a_chat_id != "41"
+ assert room_b_chat_id == "99"
+ platform.disconnect_chat.assert_awaited_once_with("41")
+ assert "сброшен" in result[0].text.lower()
+
+
+def test_register_matrix_handlers_exposes_clear_and_optional_reset_alias():
+ dispatcher = SimpleNamespace(register=Mock())
+
+ register_matrix_handlers(
+ dispatcher,
+ client=object(),
+ store=object(),
+ registry=None,
+ prototype_state=PrototypeStateStore(),
+ )
+
+ clear_calls = [
+ call
+ for call in dispatcher.register.call_args_list
+ if call.args[:2] == (IncomingCommand, "clear")
+ ]
+ reset_calls = [
+ call
+ for call in dispatcher.register.call_args_list
+ if call.args[:2] == (IncomingCommand, "reset")
+ ]
+ assert clear_calls
+ assert len(reset_calls) <= 1
+
+
+@pytest.mark.asyncio
+async def test_context_command_shows_current_snapshot():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await runtime.chat_mgr.get_or_create(
+ user_id="u1",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!room:example.org",
+ name="Chat 1",
+ )
+ await set_room_meta(
+ runtime.store,
+ "!room:example.org",
+ {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
+ )
+ await platform._prototype_state.set_current_session("41", "session-a")
+ await platform._prototype_state.set_last_tokens_used("41", 99)
+ await platform._prototype_state.add_saved_session("u1", "session-a")
+ handler = make_handle_context(store=runtime.store, prototype_state=platform._prototype_state)
+ event = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ command="context",
+ args=[],
+ )
+
+ result = await handler(
+ event,
+ runtime.auth_mgr,
+ platform,
+ runtime.chat_mgr,
+ runtime.settings_mgr,
+ )
+
+ assert "Контекст чата: 41" in result[0].text
+ assert "Сессия: session-a" in result[0].text
+ assert "Токены (последний ответ): 99" in result[0].text
+ assert "session-a" in result[0].text
+
+
+@pytest.mark.asyncio
+async def test_bot_intercepts_numeric_load_selection():
+ platform = MatrixCommandPlatform()
+ runtime = build_runtime(platform=platform)
+ await set_room_meta(
+ runtime.store,
+ "!room:example.org",
+ {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "41",
+ },
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client, runtime)
+ await set_load_pending(
+ runtime.store,
+ "@alice:example.org",
+ "!room:example.org",
+ {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
+ )
+ room = SimpleNamespace(room_id="!room:example.org")
+ event = SimpleNamespace(sender="@alice:example.org", body="1")
+
+ await bot.on_room_message(room, event)
+
+ platform.send_message.assert_awaited_once()
+ assert await platform._prototype_state.get_current_session("41") == "session-a"
+ assert await platform._prototype_state.get_current_session("C1") == "session-a"
+ client.room_send.assert_awaited_once_with(
+ "!room:example.org",
+ "m.room.message",
+ {"msgtype": "m.text", "body": "Запрос на загрузку отправлен агенту: session-a"},
+ )
diff --git a/tests/adapter/matrix/test_converter.py b/tests/adapter/matrix/test_converter.py
index ecaecdc..3513913 100644
--- a/tests/adapter/matrix/test_converter.py
+++ b/tests/adapter/matrix/test_converter.py
@@ -37,7 +37,41 @@ def image_event(url: str = "mxc://x/img", mime: str = "image/jpeg"):
)
-async def test_plain_text_to_incoming_message():
+def content_file_event():
+ return SimpleNamespace(
+ sender="@a:m.org",
+ body="doc.pdf",
+ event_id="$e4",
+ msgtype=None,
+ replyto_event_id=None,
+ content={
+ "msgtype": "m.file",
+ "body": "nested.pdf",
+ "url": "mxc://x/nested",
+ "info": {"mimetype": "application/pdf"},
+ },
+ )
+
+
+def source_only_content_file_event():
+ return SimpleNamespace(
+ sender="@a:m.org",
+ body="doc.pdf",
+ event_id="$e5",
+ msgtype=None,
+ replyto_event_id=None,
+ source={
+ "content": {
+ "msgtype": "m.file",
+ "body": "source-only.pdf",
+ "url": "mxc://x/source-only",
+ "info": {"mimetype": "application/pdf"},
+ }
+ },
+ )
+
+
+def test_plain_text_to_incoming_message():
result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingMessage)
assert result.text == "Hello"
@@ -46,20 +80,48 @@ async def test_plain_text_to_incoming_message():
assert result.attachments == []
-async def test_bang_command_to_incoming_command():
+def test_bang_command_to_incoming_command():
result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingCommand)
assert result.command == "new"
assert result.args == ["Analysis"]
-async def test_skills_alias_to_settings_command():
+def test_list_command_maps_to_matrix_list_attachments():
+ result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_list_attachments"
+ assert result.args == []
+
+
+def test_remove_all_maps_to_matrix_remove_attachment():
+ result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["all"]
+
+
+def test_remove_index_maps_to_matrix_remove_attachment():
+ result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["2"]
+
+
+def test_remove_arbitrary_index_maps_to_matrix_remove_attachment():
+ result = from_room_event(text_event("!remove 99"), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingCommand)
+ assert result.command == "matrix_remove_attachment"
+ assert result.args == ["99"]
+
+
+def test_skills_alias_to_settings_command():
result = from_command("!skills", sender="@a:m.org", chat_id="C1")
assert isinstance(result, IncomingCommand)
assert result.command == "settings_skills"
-async def test_yes_to_callback():
+def test_yes_to_callback():
result = from_room_event(text_event("!yes"), room_id="!room:example.org", chat_id="C7")
assert isinstance(result, IncomingCallback)
assert result.action == "confirm"
@@ -67,7 +129,7 @@ async def test_yes_to_callback():
assert result.payload["room_id"] == "!room:example.org"
-async def test_no_to_callback():
+def test_no_to_callback():
result = from_room_event(text_event("!no"), room_id="!room:example.org", chat_id="C7")
assert isinstance(result, IncomingCallback)
assert result.action == "cancel"
@@ -75,7 +137,7 @@ async def test_no_to_callback():
assert result.payload["room_id"] == "!room:example.org"
-async def test_file_attachment():
+def test_file_attachment():
result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingMessage)
assert len(result.attachments) == 1
@@ -86,11 +148,32 @@ async def test_file_attachment():
assert a.mime_type == "application/pdf"
-async def test_image_attachment():
+def test_image_attachment():
result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1")
assert result.attachments[0].type == "image"
+ assert result.attachments[0].filename == "img.jpg"
assert result.attachments[0].mime_type == "image/jpeg"
+def test_attachment_falls_back_to_content_payload():
+ result = from_room_event(content_file_event(), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ a = result.attachments[0]
+ assert a.type == "document"
+ assert a.url == "mxc://x/nested"
+ assert a.filename == "nested.pdf"
+ assert a.mime_type == "application/pdf"
+
+
+def test_attachment_falls_back_to_source_content_payload():
+ result = from_room_event(source_only_content_file_event(), room_id="!r:m.org", chat_id="C1")
+ assert isinstance(result, IncomingMessage)
+ a = result.attachments[0]
+ assert a.type == "document"
+ assert a.url == "mxc://x/source-only"
+ assert a.filename == "source-only.pdf"
+ assert a.mime_type == "application/pdf"
+
+
def test_converter_module_does_not_expose_reaction_callbacks():
assert not hasattr(converter, "from_reaction")
diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py
index dce9243..1240f86 100644
--- a/tests/adapter/matrix/test_dispatcher.py
+++ b/tests/adapter/matrix/test_dispatcher.py
@@ -1,15 +1,42 @@
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.store import get_room_meta, get_user_meta, set_user_meta
-from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
+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
@@ -17,7 +44,9 @@ 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")
+ start = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
+ )
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
@@ -41,7 +70,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",
@@ -51,7 +80,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():
@@ -75,15 +104,14 @@ async def test_new_chat_creates_real_matrix_room_when_client_available():
)
result = await runtime.dispatcher.dispatch(new)
- client.room_create.assert_awaited_once_with(
- name="Research",
- visibility=RoomVisibility.private,
- is_direct=False,
- invite=["u1"],
- )
+ # 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"
+ 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"]
@@ -129,7 +157,10 @@ async def test_invite_event_creates_space_and_chat_room():
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"
+ 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
@@ -178,7 +209,9 @@ async def test_invite_event_is_idempotent_per_user():
runtime.chat_mgr,
)
+ assert client.join.await_count == 2
assert client.room_create.await_count == 2
+ assert client.room_send.await_count == 2
async def test_bot_ignores_its_own_messages():
@@ -196,11 +229,731 @@ async def test_bot_ignores_its_own_messages():
bot._send_all.assert_not_awaited()
-async def test_mat11_settings_returns_dashboard():
+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"),
+ )
+ ],
+ user_agents={"@alice:example.org": "agent-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"] == "report.pdf"
+ 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" / "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"),
+ )
+ ],
+ user_agents={"@alice:example.org": "agent-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="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")
+ start = IncomingCommand(
+ user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
+ )
await runtime.dispatcher.dispatch(start)
settings_cmd = IncomingCommand(
@@ -208,15 +961,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():
@@ -229,10 +977,29 @@ 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 "!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():
@@ -254,3 +1021,90 @@ async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
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
diff --git a/tests/adapter/matrix/test_files.py b/tests/adapter/matrix/test_files.py
new file mode 100644
index 0000000..a3a9146
--- /dev/null
+++ b/tests/adapter/matrix/test_files.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from pathlib import Path
+from types import SimpleNamespace
+
+from adapter.matrix.files import (
+ build_agent_workspace_path,
+ download_matrix_attachment,
+)
+from core.protocol import Attachment
+
+
+async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
+ async def download(url: str):
+ assert url == "mxc://server/id"
+ return SimpleNamespace(body=b"%PDF-1.7")
+
+ client = SimpleNamespace(download=download)
+ attachment = Attachment(
+ type="document",
+ url="mxc://server/id",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ )
+
+ saved = await download_matrix_attachment(
+ client=client,
+ workspace_root=tmp_path,
+ matrix_user_id="@alice:example.org",
+ room_id="!room:example.org",
+ attachment=attachment,
+ timestamp="20260420-153000",
+ )
+
+ assert saved.workspace_path == "report.pdf"
+ assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7"
+
+
+def test_build_agent_workspace_path_uses_agent_workspace_volume(tmp_path: Path):
+ rel_path, abs_path = build_agent_workspace_path(
+ workspace_root=tmp_path / "agents" / "17",
+ filename="quarterly status.pdf",
+ )
+
+ assert rel_path == "quarterly status.pdf"
+ assert abs_path == tmp_path / "agents" / "17" / rel_path
+
+
+def test_build_agent_workspace_path_uses_windows_style_copy_index(tmp_path: Path):
+ workspace_root = tmp_path / "agents" / "17"
+ workspace_root.mkdir(parents=True)
+ (workspace_root / "report.pdf").write_bytes(b"old")
+ (workspace_root / "report (1).pdf").write_bytes(b"older")
+
+ rel_path, abs_path = build_agent_workspace_path(
+ workspace_root=workspace_root,
+ filename="report.pdf",
+ )
+
+ assert rel_path == "report (2).pdf"
+ assert abs_path == workspace_root / "report (2).pdf"
+
+
+def test_build_agent_workspace_path_sanitizes_to_basename(tmp_path: Path):
+ rel_path, abs_path = build_agent_workspace_path(
+ workspace_root=tmp_path / "agents" / "17",
+ filename="../../quarterly: status?.pdf",
+ )
+
+ assert rel_path == "quarterly_ status_.pdf"
+ assert abs_path == tmp_path / "agents" / "17" / "quarterly_ status_.pdf"
+
+
+async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Path):
+ async def download(url: str):
+ assert url == "mxc://server/id"
+ return SimpleNamespace(body=b"%PDF-1.7")
+
+ saved = await download_matrix_attachment(
+ client=SimpleNamespace(download=download),
+ workspace_root=tmp_path / "agents" / "17",
+ matrix_user_id="@alice:example.org",
+ room_id="!room:example.org",
+ attachment=Attachment(
+ type="document",
+ url="mxc://server/id",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ ),
+ timestamp="20260428-110000",
+ )
+
+ assert saved.workspace_path == "report.pdf"
+ assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"
diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py
index a14ef0a..15ca57c 100644
--- a/tests/adapter/matrix/test_invite_space.py
+++ b/tests/adapter/matrix/test_invite_space.py
@@ -7,7 +7,7 @@ from nio.api import RoomVisibility
from adapter.matrix.bot import build_runtime
from adapter.matrix.handlers.auth import handle_invite
-from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
+from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
from sdk.mock import MockPlatformClient
@@ -64,6 +64,7 @@ async def test_mat01_invite_creates_space_and_chat1():
assert room_meta is not None
assert room_meta["chat_id"] == "C4"
assert room_meta["space_id"] == "!space:example.org"
+ assert room_meta["platform_chat_id"] == "1"
assert user_meta["next_chat_index"] == 5
chats = await runtime.chat_mgr.list_active("@alice:example.org")
@@ -99,6 +100,53 @@ async def test_mat02_invite_idempotent():
assert client.room_create.await_count == 2
+async def test_existing_user_invite_reinvites_space_and_active_chats():
+ runtime = build_runtime(platform=MockPlatformClient())
+ await set_user_meta(
+ runtime.store,
+ "@alice:example.org",
+ {"space_id": "!space:example.org", "next_chat_index": 2},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat1:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C1",
+ "display_name": "Чат 1",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "1",
+ "agent_id": "agent-1",
+ },
+ )
+ await runtime.chat_mgr.get_or_create(
+ user_id="@alice:example.org",
+ chat_id="C1",
+ platform="matrix",
+ surface_ref="!chat1:example.org",
+ name="Чат 1",
+ )
+ 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,
+ runtime.chat_mgr,
+ )
+
+ client.room_create.assert_not_awaited()
+ client.room_invite.assert_any_await("!space:example.org", "@alice:example.org")
+ client.room_invite.assert_any_await("!chat1:example.org", "@alice:example.org")
+ client.room_send.assert_awaited()
+
+
async def test_mat03_no_hardcoded_c1():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7})
@@ -119,6 +167,7 @@ async def test_mat03_no_hardcoded_c1():
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
assert room_meta is not None
assert room_meta["chat_id"] == "C7"
+ assert room_meta["platform_chat_id"] == "1"
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None
diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py
new file mode 100644
index 0000000..c44ffc0
--- /dev/null
+++ b/tests/adapter/matrix/test_reconciliation.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
+from adapter.matrix.bot import MatrixBot, build_runtime
+from adapter.matrix.reconciliation import reconcile_startup_state
+from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
+from sdk.mock import MockPlatformClient
+
+
+def _room(
+ room_id: str,
+ name: str,
+ members: list[str],
+ *,
+ parents: tuple[str, ...] = (),
+):
+ return SimpleNamespace(
+ room_id=room_id,
+ name=name,
+ display_name=name,
+ users={user_id: SimpleNamespace(user_id=user_id) for user_id in members},
+ space_parents=set(parents),
+ )
+
+
+async def test_reconcile_startup_state_restores_space_room_and_chat_bindings():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ )
+
+ await reconcile_startup_state(client, runtime)
+
+ 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"
+ assert user_meta["next_chat_index"] == 4
+
+ room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
+ assert room_meta is not None
+ assert room_meta["room_type"] == "chat"
+ assert room_meta["chat_id"] == "C3"
+ assert room_meta["space_id"] == "!space:example.org"
+ assert room_meta["matrix_user_id"] == "@alice:example.org"
+ assert room_meta["platform_chat_id"] == "1"
+
+ chats = await runtime.chat_mgr.list_active("@alice:example.org")
+ assert [chat.chat_id for chat in chats] == ["C3"]
+ assert [chat.surface_ref for chat in chats] == ["!chat3:example.org"]
+
+
+async def test_reconcile_startup_state_is_idempotent_with_existing_local_state():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ )
+ await set_user_meta(
+ runtime.store,
+ "@alice:example.org",
+ {"space_id": "!space:example.org", "next_chat_index": 8},
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat3:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Existing name",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ },
+ )
+ await runtime.chat_mgr.get_or_create(
+ user_id="@alice:example.org",
+ chat_id="C3",
+ platform="matrix",
+ surface_ref="!chat3:example.org",
+ name="Existing name",
+ )
+
+ await reconcile_startup_state(client, runtime)
+ await reconcile_startup_state(client, runtime)
+
+ user_meta = await get_user_meta(runtime.store, "@alice:example.org")
+ assert user_meta == {"space_id": "!space:example.org", "next_chat_index": 8}
+
+ room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
+ assert room_meta is not None
+ assert room_meta["display_name"] == "Existing name"
+ assert room_meta["platform_chat_id"] == "42"
+
+ chats = await runtime.chat_mgr.list_active("@alice:example.org")
+ assert len(chats) == 1
+ assert chats[0].chat_id == "C3"
+
+
+async def test_reconcile_updates_default_agent_assignment_after_user_is_configured():
+ runtime = build_runtime(platform=MockPlatformClient())
+ runtime.registry = AgentRegistry(
+ [
+ AgentDefinition("agent-default", "Default"),
+ AgentDefinition("agent-alice", "Alice"),
+ ],
+ user_agents={"@alice:example.org": "agent-alice"},
+ )
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ )
+ await set_room_meta(
+ runtime.store,
+ "!chat3:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C3",
+ "display_name": "Чат 3",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ "platform_chat_id": "42",
+ "agent_id": "agent-default",
+ "agent_assignment": "default",
+ },
+ )
+
+ await reconcile_startup_state(client, runtime)
+
+ room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
+ assert room_meta is not None
+ assert room_meta["agent_id"] == "agent-alice"
+ assert room_meta["agent_assignment"] == "configured"
+ assert room_meta["platform_chat_id"] == "42"
+
+
+async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room():
+ runtime = build_runtime(platform=MockPlatformClient())
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": _room(
+ "!space:example.org",
+ "Lambda - Alice",
+ ["@bot:example.org", "@alice:example.org"],
+ ),
+ "!chat3:example.org": _room(
+ "!chat3:example.org",
+ "Чат 3",
+ ["@bot:example.org", "@alice:example.org"],
+ parents=("!space:example.org",),
+ ),
+ },
+ room_send=AsyncMock(),
+ )
+ bot = MatrixBot(client=client, runtime=runtime)
+ bot._bootstrap_unregistered_room = AsyncMock()
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+
+ await reconcile_startup_state(client, runtime)
+ await bot.on_room_message(
+ SimpleNamespace(room_id="!chat3:example.org"),
+ SimpleNamespace(sender="@alice:example.org", body="hello"),
+ )
+
+ bot._bootstrap_unregistered_room.assert_not_awaited()
+ runtime.dispatcher.dispatch.assert_awaited_once()
+
+
+async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch):
+ bot_module = importlib.import_module("adapter.matrix.bot")
+
+ runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
+ call_order: list[str] = []
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ self.access_token = None
+ self.callbacks = []
+ self.close = AsyncMock()
+ self.sync_forever = AsyncMock(side_effect=self._sync_forever)
+
+ async def _sync_forever(self, *args, **kwargs):
+ call_order.append("sync_forever")
+
+ 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))
+
+ async def fake_prepare_live_sync(client):
+ call_order.append("prepare_live_sync")
+ return "s123"
+
+ async def fake_reconcile_startup_state(client, runtime):
+ call_order.append("reconcile_startup_state")
+
+ 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", fake_prepare_live_sync)
+ monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state)
+
+ await bot_module.main()
+
+ assert call_order == [
+ "prepare_live_sync",
+ "reconcile_startup_state",
+ "sync_forever",
+ ]
diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py
new file mode 100644
index 0000000..ac05423
--- /dev/null
+++ b/tests/adapter/matrix/test_restart_persistence.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from adapter.matrix.bot import build_runtime
+from adapter.matrix.reconciliation import reconcile_startup_state
+from adapter.matrix.store import (
+ get_room_meta,
+ next_platform_chat_id,
+ set_room_meta,
+)
+from core.store import SQLiteStore
+from sdk.mock import MockPlatformClient
+
+
+async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ await set_room_meta(store, "!room:example.org", {
+ "room_type": "chat",
+ "agent_id": "agent-1",
+ "platform_chat_id": "42",
+ })
+
+ store2 = SQLiteStore(db)
+ meta = await get_room_meta(store2, "!room:example.org")
+ assert meta is not None
+ assert meta["agent_id"] == "agent-1"
+ assert meta["platform_chat_id"] == "42"
+
+
+async def test_platform_chat_seq_survives_restart(tmp_path):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ assert await next_platform_chat_id(store) == "1"
+ assert await next_platform_chat_id(store) == "2"
+ assert await next_platform_chat_id(store) == "3"
+
+ store2 = SQLiteStore(db)
+ assert await next_platform_chat_id(store2) == "4"
+
+
+async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ await set_room_meta(store, "!convo:example.org", {
+ "room_type": "chat",
+ "agent_id": "agent-1",
+ "platform_chat_id": "10",
+ })
+
+ store2 = SQLiteStore(db)
+ meta = await get_room_meta(store2, "!convo:example.org")
+ assert meta is not None
+ assert meta["agent_id"] == "agent-1"
+ assert meta["platform_chat_id"] == "10"
+
+
+async def test_missing_durable_store_starts_clean(tmp_path):
+ db = str(tmp_path / "brand_new.db")
+ store = SQLiteStore(db)
+ assert await get_room_meta(store, "!nonexistent:example.org") is None
+
+
+async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes(
+ tmp_path,
+):
+ db = str(tmp_path / "state.db")
+ store = SQLiteStore(db)
+ await set_room_meta(
+ store,
+ "!chat2:example.org",
+ {
+ "room_type": "chat",
+ "chat_id": "C2",
+ "display_name": "Чат 2",
+ "matrix_user_id": "@alice:example.org",
+ "space_id": "!space:example.org",
+ },
+ )
+
+ runtime = build_runtime(platform=MockPlatformClient(), store=store)
+ client = SimpleNamespace(
+ user_id="@bot:example.org",
+ rooms={
+ "!space:example.org": SimpleNamespace(
+ room_id="!space:example.org",
+ name="Lambda - Alice",
+ display_name="Lambda - Alice",
+ users={
+ "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"),
+ "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"),
+ },
+ space_parents=set(),
+ ),
+ "!chat2:example.org": SimpleNamespace(
+ room_id="!chat2:example.org",
+ name="Чат 2",
+ display_name="Чат 2",
+ users={
+ "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"),
+ "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"),
+ },
+ space_parents={"!space:example.org"},
+ ),
+ },
+ )
+
+ await reconcile_startup_state(client, runtime)
+
+ store2 = SQLiteStore(db)
+ room_meta = await get_room_meta(store2, "!chat2:example.org")
+ assert room_meta is not None
+ assert room_meta["platform_chat_id"] == "1"
diff --git a/tests/adapter/matrix/test_routed_platform.py b/tests/adapter/matrix/test_routed_platform.py
new file mode 100644
index 0000000..c3efca5
--- /dev/null
+++ b/tests/adapter/matrix/test_routed_platform.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from adapter.matrix.bot import MatrixBot, build_runtime
+from adapter.matrix.routed_platform import RoutedPlatformClient
+from adapter.matrix.store import set_room_meta
+from core.chat import ChatManager
+from core.store import InMemoryStore
+from sdk.interface import MessageChunk, MessageResponse, User, UserSettings
+from sdk.mock import MockPlatformClient
+from sdk.interface import PlatformError
+
+
+class FakeDelegate:
+ def __init__(self, *, name: str) -> None:
+ self.name = name
+ self.send_calls: list[dict] = []
+ self.stream_calls: list[dict] = []
+ self.user_calls: list[dict] = []
+ self.settings_calls: list[str] = []
+ self.update_calls: list[tuple[str, object]] = []
+
+ async def get_or_create_user(
+ self,
+ external_id: str,
+ platform: str,
+ display_name: str | None = None,
+ ) -> User:
+ self.user_calls.append(
+ {
+ "external_id": external_id,
+ "platform": platform,
+ "display_name": display_name,
+ }
+ )
+ return User(
+ user_id=f"user-{self.name}",
+ external_id=external_id,
+ platform=platform,
+ display_name=display_name,
+ created_at="2025-01-01T00:00:00Z",
+ is_new=False,
+ )
+
+ async def send_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments=None,
+ ) -> MessageResponse:
+ self.send_calls.append(
+ {
+ "user_id": user_id,
+ "chat_id": chat_id,
+ "text": text,
+ "attachments": attachments,
+ }
+ )
+ return MessageResponse(
+ message_id=f"msg-{self.name}",
+ response=f"reply-{self.name}",
+ tokens_used=0,
+ finished=True,
+ )
+
+ async def stream_message(
+ self,
+ user_id: str,
+ chat_id: str,
+ text: str,
+ attachments=None,
+ ) -> AsyncIterator[MessageChunk]:
+ self.stream_calls.append(
+ {
+ "user_id": user_id,
+ "chat_id": chat_id,
+ "text": text,
+ "attachments": attachments,
+ }
+ )
+ yield MessageChunk(
+ message_id=f"stream-{self.name}",
+ delta=f"delta-{self.name}",
+ finished=True,
+ tokens_used=0,
+ )
+
+ async def get_settings(self, user_id: str) -> UserSettings:
+ self.settings_calls.append(user_id)
+ return UserSettings(skills={"files": True})
+
+ async def update_settings(self, user_id: str, action: object) -> None:
+ self.update_calls.append((user_id, action))
+
+
+@pytest.fixture(autouse=True)
+def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
+ monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False)
+
+
+@pytest.mark.asyncio
+async def test_send_message_routes_by_room_agent_and_platform_chat_id():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+ delegates = {
+ "agent-1": FakeDelegate(name="agent-1"),
+ "agent-2": FakeDelegate(name="agent-2"),
+ }
+ platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
+
+ response = await platform.send_message("u1", "C1", "hello", attachments=[])
+
+ assert response.response == "reply-agent-2"
+ assert delegates["agent-1"].send_calls == []
+ assert delegates["agent-2"].send_calls == [
+ {
+ "user_id": "u1",
+ "chat_id": "41",
+ "text": "hello",
+ "attachments": [],
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_stream_message_routes_by_room_agent_and_platform_chat_id():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "41", "agent_id": "agent-2"},
+ )
+ delegates = {
+ "agent-1": FakeDelegate(name="agent-1"),
+ "agent-2": FakeDelegate(name="agent-2"),
+ }
+ platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
+
+ chunks = [chunk async for chunk in platform.stream_message("u1", "C1", "hello")]
+
+ assert [chunk.delta for chunk in chunks] == ["delta-agent-2"]
+ assert delegates["agent-1"].stream_calls == []
+ assert delegates["agent-2"].stream_calls == [
+ {
+ "user_id": "u1",
+ "chat_id": "41",
+ "text": "hello",
+ "attachments": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_send_message_fails_fast_when_platform_chat_id_is_missing():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"agent_id": "agent-2"},
+ )
+ platform = RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates={"agent-2": FakeDelegate(name="agent-2")},
+ )
+
+ with pytest.raises(PlatformError, match="routing is incomplete") as exc_info:
+ await platform.send_message("u1", "C1", "hello")
+
+ assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE"
+
+
+@pytest.mark.asyncio
+async def test_stream_message_fails_fast_when_agent_id_is_missing():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "41"},
+ )
+ platform = RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates={"agent-2": FakeDelegate(name="agent-2")},
+ )
+
+ with pytest.raises(PlatformError, match="routing is incomplete") as exc_info:
+ await anext(platform.stream_message("u1", "C1", "hello"))
+
+ assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE"
+
+
+@pytest.mark.asyncio
+async def test_routing_uses_repaired_room_metadata_without_runtime_backfill():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
+ await set_room_meta(
+ store,
+ "!room:example.org",
+ {"platform_chat_id": "restored-41", "agent_id": "agent-2"},
+ )
+ delegate = FakeDelegate(name="agent-2")
+ platform = RoutedPlatformClient(
+ chat_mgr=chat_mgr,
+ store=store,
+ delegates={"agent-2": delegate},
+ )
+
+ await platform.send_message("u1", "C1", "hello")
+
+ assert delegate.send_calls == [
+ {
+ "user_id": "u1",
+ "chat_id": "restored-41",
+ "text": "hello",
+ "attachments": None,
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_user_and_settings_delegate_to_default_client():
+ store = InMemoryStore()
+ chat_mgr = ChatManager(None, store)
+ delegates = {
+ "agent-1": FakeDelegate(name="agent-1"),
+ "agent-2": FakeDelegate(name="agent-2"),
+ }
+ platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
+
+ user = await platform.get_or_create_user("ext-1", "matrix", display_name="Alice")
+ settings = await platform.get_settings("u1")
+ await platform.update_settings("u1", {"action": "noop"})
+
+ assert user.user_id == "user-agent-1"
+ assert settings.skills == {"files": True}
+ assert delegates["agent-1"].user_calls == [
+ {
+ "external_id": "ext-1",
+ "platform": "matrix",
+ "display_name": "Alice",
+ }
+ ]
+ assert delegates["agent-2"].user_calls == []
+ assert delegates["agent-1"].settings_calls == ["u1"]
+ assert delegates["agent-1"].update_calls == [("u1", {"action": "noop"})]
+
+
+@pytest.mark.asyncio
+async def test_build_runtime_real_backend_uses_routed_platform_with_registry(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ registry_path = tmp_path / "matrix-agents.yaml"
+ registry_path.write_text(
+ "agents:\n"
+ " - id: agent-1\n"
+ " label: Analyst\n"
+ " - id: agent-2\n"
+ " label: Research\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+
+ runtime = build_runtime()
+
+ assert isinstance(runtime.platform, RoutedPlatformClient)
+ assert set(runtime.platform._delegates) == {"agent-1", "agent-2"}
+ assert runtime.platform._delegates["agent-1"].agent_base_url == "http://agent.example"
+ assert runtime.platform._delegates["agent-1"].agent_id == "agent-1"
+ assert runtime.platform._delegates["agent-2"].agent_id == "agent-2"
+
+
+def test_build_runtime_real_backend_requires_registry_path(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+
+ with pytest.raises(RuntimeError, match="MATRIX_AGENT_REGISTRY_PATH"):
+ build_runtime()
+
+
+def test_build_runtime_real_backend_fails_explicitly_on_invalid_registry(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+):
+ registry_path = tmp_path / "missing.yaml"
+ monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
+ monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
+ monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
+
+ with pytest.raises(RuntimeError, match="failed to load matrix agent registry"):
+ build_runtime()
+
+
+@pytest.mark.asyncio
+async def test_bot_keeps_local_chat_id_for_plain_message_dispatch():
+ 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",
+ "agent_id": "agent-2",
+ },
+ )
+ runtime.dispatcher.dispatch = AsyncMock(return_value=[])
+ bot = MatrixBot(SimpleNamespace(user_id="@bot:example.org"), runtime)
+
+ await bot.on_room_message(
+ SimpleNamespace(room_id="!chat1:example.org"),
+ SimpleNamespace(sender="@alice:example.org", body="hello"),
+ )
+
+ dispatched = runtime.dispatcher.dispatch.await_args.args[0]
+ assert dispatched.chat_id == "C1"
+ assert dispatched.text == "hello"
diff --git a/tests/adapter/matrix/test_send_outgoing.py b/tests/adapter/matrix/test_send_outgoing.py
index 17eeefa..72b9fa6 100644
--- a/tests/adapter/matrix/test_send_outgoing.py
+++ b/tests/adapter/matrix/test_send_outgoing.py
@@ -9,7 +9,7 @@ from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_conf
from adapter.matrix.store import get_pending_confirm, set_room_meta
from core.auth import AuthManager
from core.chat import ChatManager
-from core.protocol import OutgoingUI, UIButton
+from core.protocol import Attachment, OutgoingMessage, OutgoingUI, UIButton
from core.settings import SettingsManager
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
@@ -156,3 +156,39 @@ async def test_outgoing_ui_no_round_trip_uses_user_and_room_scope():
assert "отменено" in result[0].text.lower()
assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None
+
+
+async def test_send_outgoing_uploads_workspace_file_attachment(tmp_path, monkeypatch):
+ workspace_file = tmp_path / "surfaces" / "matrix" / "alice" / "room" / "inbox" / "result.txt"
+ workspace_file.parent.mkdir(parents=True, exist_ok=True)
+ workspace_file.write_text("ready")
+ monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
+
+ client = SimpleNamespace(
+ upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
+ room_send=AsyncMock(),
+ )
+
+ await send_outgoing(
+ client,
+ "!room:example.org",
+ OutgoingMessage(
+ chat_id="!room:example.org",
+ text="Файл готов",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="result.txt",
+ mime_type="text/plain",
+ workspace_path="surfaces/matrix/alice/room/inbox/result.txt",
+ )
+ ],
+ ),
+ )
+
+ client.upload.assert_awaited_once()
+ client.room_send.assert_awaited()
+ assert client.room_send.await_args_list[0].args[2]["body"] == "Файл готов"
+ file_call = client.room_send.await_args_list[1]
+ assert file_call.args[2]["msgtype"] == "m.file"
+ assert file_call.args[2]["url"] == "mxc://server/file"
diff --git a/tests/adapter/matrix/test_store.py b/tests/adapter/matrix/test_store.py
index 35f8131..7c4a216 100644
--- a/tests/adapter/matrix/test_store.py
+++ b/tests/adapter/matrix/test_store.py
@@ -3,14 +3,22 @@ from __future__ import annotations
import pytest
from adapter.matrix.store import (
+ STAGED_ATTACHMENTS_PREFIX,
+ add_staged_attachment,
clear_pending_confirm,
+ clear_staged_attachments,
get_pending_confirm,
+ get_platform_chat_id,
get_room_meta,
get_room_state,
get_skills_message_id,
+ get_staged_attachments,
get_user_meta,
next_chat_id,
+ next_platform_chat_id,
+ remove_staged_attachment_at,
set_pending_confirm,
+ set_platform_chat_id,
set_room_meta,
set_room_state,
set_skills_message_id,
@@ -35,6 +43,36 @@ async def test_room_meta_roundtrip(store: InMemoryStore):
assert await get_room_meta(store, "!r:m.org") == meta
+async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore):
+ meta = {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "platform_chat_id": "chat-platform-1",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ saved = await get_room_meta(store, "!r:m.org")
+ assert saved is not None
+ assert saved["platform_chat_id"] == "chat-platform-1"
+
+
+async def test_platform_chat_id_helpers_roundtrip(store: InMemoryStore):
+ meta = {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "display_name": "Research",
+ }
+ await set_room_meta(store, "!r:m.org", meta)
+ await set_platform_chat_id(store, "!r:m.org", "chat-platform-1")
+
+ assert await get_platform_chat_id(store, "!r:m.org") == "chat-platform-1"
+ assert await get_room_meta(store, "!r:m.org") == {
+ "chat_id": "C1",
+ "matrix_user_id": "@alice:example.org",
+ "display_name": "Research",
+ "platform_chat_id": "chat-platform-1",
+ }
+
+
async def test_room_meta_missing(store: InMemoryStore):
assert await get_room_meta(store, "!nonexistent:m.org") is None
@@ -70,6 +108,12 @@ async def test_next_chat_id_increments(store: InMemoryStore):
assert await next_chat_id(store, uid) == "C3"
+async def test_next_platform_chat_id_increments(store: InMemoryStore):
+ assert await next_platform_chat_id(store) == "1"
+ assert await next_platform_chat_id(store) == "2"
+ assert await next_platform_chat_id(store) == "3"
+
+
async def test_skills_message_roundtrip(store: InMemoryStore):
await set_skills_message_id(store, "!room", "$event")
assert await get_skills_message_id(store, "!room") == "$event"
@@ -84,3 +128,119 @@ async def test_pending_confirm_roundtrip(store: InMemoryStore):
await clear_pending_confirm(store, "!room:m.org")
assert await get_pending_confirm(store, "!room:m.org") is None
+
+
+async def test_staged_attachments_roundtrip(store: InMemoryStore):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+
+ assert await get_staged_attachments(store, room_id, user_id) == []
+
+ first = {"id": "att-1", "name": "screenshot.png"}
+ second = {"id": "att-2", "name": "invoice.pdf"}
+
+ await add_staged_attachment(store, room_id, user_id, first)
+ await add_staged_attachment(store, room_id, user_id, second)
+
+ assert await get_staged_attachments(store, room_id, user_id) == [
+ first,
+ second,
+ ]
+
+
+@pytest.mark.parametrize(
+ "stored_value",
+ [
+ None,
+ "not-a-dict",
+ [],
+ 123,
+ ],
+)
+async def test_staged_attachments_invalid_container_state_returns_empty_list(
+ store: InMemoryStore,
+ stored_value,
+):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+
+ await store.set(f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}", stored_value)
+
+ assert await get_staged_attachments(store, room_id, user_id) == []
+
+
+async def test_staged_attachments_filters_invalid_entries(store: InMemoryStore):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+ valid_one = {"id": "att-1", "name": "alpha.png"}
+ valid_two = {"id": "att-2", "name": "beta.pdf"}
+
+ await store.set(
+ f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}",
+ {
+ "attachments": [
+ valid_one,
+ "bad-entry",
+ None,
+ {"id": "ignored"},
+ valid_two,
+ ]
+ },
+ )
+
+ assert await get_staged_attachments(store, room_id, user_id) == [
+ valid_one,
+ {"id": "ignored"},
+ valid_two,
+ ]
+
+
+async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
+ room_a = "!room-a:m.org"
+ room_b = "!room-b:m.org"
+ user_a = "@alice:m.org"
+ user_b = "@bob:m.org"
+
+ attachment_a = {"id": "att-a", "name": "alpha.png"}
+ attachment_b = {"id": "att-b", "name": "beta.png"}
+ attachment_c = {"id": "att-c", "name": "gamma.png"}
+
+ await add_staged_attachment(store, room_a, user_a, attachment_a)
+ await add_staged_attachment(store, room_a, user_b, attachment_b)
+ await add_staged_attachment(store, room_b, user_a, attachment_c)
+
+ assert await get_staged_attachments(store, room_a, user_a) == [attachment_a]
+ assert await get_staged_attachments(store, room_a, user_b) == [attachment_b]
+ assert await get_staged_attachments(store, room_b, user_a) == [attachment_c]
+ assert await get_staged_attachments(store, room_b, user_b) == []
+
+
+async def test_remove_staged_attachment_at_by_zero_based_index(
+ store: InMemoryStore,
+):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+ first = {"id": "att-1", "name": "first.png"}
+ second = {"id": "att-2", "name": "second.png"}
+ third = {"id": "att-3", "name": "third.png"}
+
+ await add_staged_attachment(store, room_id, user_id, first)
+ await add_staged_attachment(store, room_id, user_id, second)
+ await add_staged_attachment(store, room_id, user_id, third)
+
+ assert await remove_staged_attachment_at(store, room_id, user_id, 1) == second
+ assert await get_staged_attachments(store, room_id, user_id) == [first, third]
+ assert await remove_staged_attachment_at(store, room_id, user_id, 99) is None
+ assert await remove_staged_attachment_at(store, room_id, user_id, -1) is None
+
+
+async def test_clear_staged_attachments(store: InMemoryStore):
+ room_id = "!room:m.org"
+ user_id = "@alice:m.org"
+
+ await add_staged_attachment(store, room_id, user_id, {"id": "att-1"})
+ await add_staged_attachment(store, room_id, user_id, {"id": "att-2"})
+
+ await clear_staged_attachments(store, room_id, user_id)
+
+ assert await get_staged_attachments(store, room_id, user_id) == []
diff --git a/tests/core/test_dispatcher.py b/tests/core/test_dispatcher.py
index eb437d2..fad2a4f 100644
--- a/tests/core/test_dispatcher.py
+++ b/tests/core/test_dispatcher.py
@@ -75,6 +75,27 @@ async def test_dispatch_routes_audio_before_catchall(dispatcher):
assert (await dispatcher.dispatch(text_msg))[0].text == "text"
+async def test_dispatch_routes_document_before_catchall(dispatcher):
+ async def document_handler(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="document")]
+
+ async def catch_all(event, **kwargs):
+ return [OutgoingMessage(chat_id=event.chat_id, text="text")]
+
+ dispatcher.register(IncomingMessage, "document", document_handler)
+ dispatcher.register(IncomingMessage, "*", catch_all)
+
+ document_msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="",
+ attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.pdf")],
+ )
+
+ assert (await dispatcher.dispatch(document_msg))[0].text == "document"
+
+
async def test_dispatch_callback_by_action(dispatcher):
async def confirm_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")]
diff --git a/tests/core/test_integration.py b/tests/core/test_integration.py
index 207a0ba..9260ec8 100644
--- a/tests/core/test_integration.py
+++ b/tests/core/test_integration.py
@@ -4,18 +4,57 @@ Smoke test: полный цикл через dispatcher + реальные manag
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
-from sdk.mock import MockPlatformClient
-from core.store import InMemoryStore
-from core.chat import ChatManager
+
from core.auth import AuthManager
-from core.settings import SettingsManager
+from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
- IncomingCommand, IncomingMessage, IncomingCallback,
- OutgoingMessage, OutgoingUI,
- Attachment, SettingsAction,
+ Attachment,
+ IncomingCallback,
+ IncomingCommand,
+ IncomingMessage,
+ OutgoingMessage,
+ OutgoingUI,
)
+from core.settings import SettingsManager
+from core.store import InMemoryStore
+from sdk.mock import MockPlatformClient
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+from sdk.upstream_agent_api import MsgEventTextChunk
+
+
+class FakeAgentApi:
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ self.agent_id = agent_id
+ self.base_url = base_url
+ self.chat_id = chat_id
+ self.calls: list[tuple[str, list[str]]] = []
+ self.connect_calls = 0
+ self.close_calls = 0
+
+ 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, attachments: list[str] | None = None):
+ self.calls.append((text, attachments or []))
+ yield MsgEventTextChunk(text=f"[REAL] {text}")
+
+
+class FakeAgentApiFactory:
+ def __init__(self) -> None:
+ self.created_chat_ids: list[str] = []
+ self.instances: dict[str, list[FakeAgentApi]] = {}
+
+ def __call__(self, agent_id: str, base_url: str, chat_id: str) -> FakeAgentApi:
+ chat_api = FakeAgentApi(agent_id, base_url, chat_id)
+ self.created_chat_ids.append(chat_id)
+ self.instances.setdefault(chat_id, []).append(chat_api)
+ return chat_api
@pytest.fixture
@@ -32,6 +71,27 @@ def dispatcher():
return d
+@pytest.fixture
+def real_dispatcher():
+ agent_api = FakeAgentApiFactory()
+ platform = RealPlatformClient(
+ agent_id="matrix-bot",
+ agent_base_url="http://platform-agent:8000",
+ agent_api_cls=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+ store = InMemoryStore()
+ d = EventDispatcher(
+ platform=platform,
+ chat_mgr=ChatManager(platform, store),
+ auth_mgr=AuthManager(platform, store),
+ settings_mgr=SettingsManager(platform, store),
+ )
+ register_all(d)
+ return d, agent_api
+
+
async def test_full_flow_start_then_message(dispatcher):
start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start")
result = await dispatcher.dispatch(start)
@@ -47,7 +107,13 @@ async def test_new_chat_command(dispatcher):
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await dispatcher.dispatch(start)
- new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"])
+ new = IncomingCommand(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C2",
+ command="new",
+ args=["Анализ"],
+ )
result = await dispatcher.dispatch(new)
assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))
@@ -83,3 +149,46 @@ async def test_toggle_skill_callback(dispatcher):
)
result = await dispatcher.dispatch(cb)
assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))
+
+
+async def test_full_flow_with_real_platform_uses_direct_agent_api(real_dispatcher):
+ dispatcher, agent_api = real_dispatcher
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
+ result = await dispatcher.dispatch(start)
+ assert any(isinstance(r, OutgoingMessage) for r in result)
+
+ msg = IncomingMessage(user_id="u1", platform="matrix", chat_id="C1", text="Привет!")
+ result = await dispatcher.dispatch(msg)
+ texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
+
+ assert texts == ["[REAL] Привет!"]
+ assert agent_api.created_chat_ids == ["C1"]
+ assert [instance.calls for instance in agent_api.instances["C1"]] == [[("Привет!", [])]]
+
+
+async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
+ dispatcher, agent_api = real_dispatcher
+
+ start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
+ await dispatcher.dispatch(start)
+
+ msg = IncomingMessage(
+ user_id="u1",
+ platform="matrix",
+ chat_id="C1",
+ text="Посмотри файл",
+ attachments=[
+ Attachment(
+ type="document",
+ filename="report.pdf",
+ mime_type="application/pdf",
+ workspace_path="surfaces/matrix/u1/room/inbox/report.pdf",
+ )
+ ],
+ )
+ await dispatcher.dispatch(msg)
+
+ assert [instance.calls for instance in agent_api.instances["C1"]] == [
+ [("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])]
+ ]
diff --git a/tests/platform/test_agent_session.py b/tests/platform/test_agent_session.py
new file mode 100644
index 0000000..c398e8c
--- /dev/null
+++ b/tests/platform/test_agent_session.py
@@ -0,0 +1,27 @@
+"""Compatibility tests after the Phase 4 migration."""
+
+from pathlib import Path
+
+
+def test_lambda_agent_api_module_is_importable():
+ from sdk.upstream_agent_api import AgentApi
+
+ assert AgentApi is not None
+
+
+def test_lambda_agent_api_preserves_base_url_path_suffix():
+ from sdk.upstream_agent_api import AgentApi
+
+ api = AgentApi(
+ agent_id="matrix-bot",
+ base_url="http://platform-agent:8000/proxy/",
+ chat_id="chat-7",
+ )
+
+ assert api.url == "http://platform-agent:8000/proxy/v1/agent_ws/chat-7/"
+
+
+def test_agent_session_module_is_intentionally_stubbed():
+ contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py"
+
+ assert "replaced by direct AgentApi usage" in contents.read_text()
diff --git a/tests/platform/test_prototype_state.py b/tests/platform/test_prototype_state.py
new file mode 100644
index 0000000..376c0c4
--- /dev/null
+++ b/tests/platform/test_prototype_state.py
@@ -0,0 +1,184 @@
+import pytest
+
+from core.protocol import SettingsAction
+from sdk.interface import UserSettings
+from sdk.prototype_state import PrototypeStateStore
+
+
+@pytest.mark.asyncio
+async def test_get_or_create_user_is_stable_per_surface_identity():
+ store = PrototypeStateStore()
+
+ first = await store.get_or_create_user("@alice:example.org", "matrix", "Alice")
+ second = await store.get_or_create_user("@alice:example.org", "matrix")
+
+ assert first.user_id == "usr-matrix-@alice:example.org"
+ assert first.is_new is True
+ assert store._users["matrix:@alice:example.org"].is_new is False
+
+ first.display_name = "Mallory"
+ first.is_new = False
+
+ assert second.user_id == first.user_id
+ assert second.is_new is False
+ assert second.display_name == "Alice"
+ assert store._users["matrix:@alice:example.org"].display_name == "Alice"
+ assert store._users["matrix:@alice:example.org"].is_new is False
+
+
+@pytest.mark.asyncio
+async def test_settings_defaults_match_existing_mock_shape():
+ store = PrototypeStateStore()
+
+ settings = await store.get_settings("usr-matrix-@alice:example.org")
+
+ assert isinstance(settings, UserSettings)
+ assert settings.skills == {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+ }
+ assert settings.safety == {
+ "email-send": True,
+ "file-delete": True,
+ "social-post": True,
+ }
+ assert settings.soul == {"name": "Лямбда", "instructions": ""}
+ assert settings.plan == {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000}
+
+
+@pytest.mark.asyncio
+async def test_get_settings_returns_connectors_copy():
+ store = PrototypeStateStore()
+ store._settings["usr-matrix-@alice:example.org"] = {
+ "connectors": {"github": {"enabled": True}},
+ }
+
+ settings = await store.get_settings("usr-matrix-@alice:example.org")
+ settings.connectors["github"]["enabled"] = False
+ settings.connectors["slack"] = {"enabled": True}
+
+ assert store._settings["usr-matrix-@alice:example.org"]["connectors"] == {
+ "github": {"enabled": True},
+ }
+
+
+@pytest.mark.asyncio
+async def test_update_settings_supports_toggle_skill_and_setters():
+ store = PrototypeStateStore()
+
+ await store.update_settings(
+ "usr-matrix-@alice:example.org",
+ SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
+ )
+ await store.update_settings(
+ "usr-matrix-@alice:example.org",
+ SettingsAction(action="set_soul", payload={"field": "instructions", "value": "Be concise"}),
+ )
+ await store.update_settings(
+ "usr-matrix-@alice:example.org",
+ SettingsAction(action="set_safety", payload={"trigger": "social-post", "enabled": False}),
+ )
+
+ settings = await store.get_settings("usr-matrix-@alice:example.org")
+
+ assert settings.skills["browser"] is True
+ assert settings.skills["web-search"] is True
+ assert settings.soul["instructions"] == "Be concise"
+ assert settings.safety["social-post"] is False
+
+
+@pytest.mark.asyncio
+async def test_add_saved_session_appends_named_entries():
+ store = PrototypeStateStore()
+
+ await store.add_saved_session(
+ "usr-matrix-@alice:example.org",
+ "alpha",
+ source_context_id="ctx-room-1",
+ )
+ await store.add_saved_session("usr-matrix-@alice:example.org", "beta")
+
+ sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+
+ assert [session["name"] for session in sessions] == ["alpha", "beta"]
+ assert all("created_at" in session for session in sessions)
+ assert sessions[0]["source_context_id"] == "ctx-room-1"
+
+
+@pytest.mark.asyncio
+async def test_list_saved_sessions_returns_copy():
+ store = PrototypeStateStore()
+
+ await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
+
+ sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+ sessions.append({"name": "tampered", "created_at": "never"})
+
+ stored = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+
+ assert [session["name"] for session in stored] == ["alpha"]
+
+
+@pytest.mark.asyncio
+async def test_get_last_tokens_used_defaults_to_zero():
+ store = PrototypeStateStore()
+
+ assert await store.get_last_tokens_used_for_context("ctx-room-1") == 0
+
+
+@pytest.mark.asyncio
+async def test_live_tokens_used_are_scoped_per_context():
+ store = PrototypeStateStore()
+
+ await store.set_last_tokens_used_for_context("ctx-room-1", 321)
+ await store.set_last_tokens_used_for_context("ctx-room-2", 654)
+
+ assert await store.get_last_tokens_used_for_context("ctx-room-1") == 321
+ assert await store.get_last_tokens_used_for_context("ctx-room-2") == 654
+
+
+@pytest.mark.asyncio
+async def test_current_session_roundtrip_is_scoped_per_context():
+ store = PrototypeStateStore()
+
+ assert await store.get_current_session_for_context("ctx-room-1") is None
+ assert await store.get_current_session_for_context("ctx-room-2") is None
+
+ await store.set_current_session_for_context("ctx-room-1", "session-1")
+ await store.set_current_session_for_context("ctx-room-2", "session-2")
+
+ assert await store.get_current_session_for_context("ctx-room-1") == "session-1"
+ assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
+
+
+@pytest.mark.asyncio
+async def test_clear_current_session_removes_only_target_context():
+ store = PrototypeStateStore()
+
+ await store.set_current_session_for_context("ctx-room-1", "session-1")
+ await store.set_current_session_for_context("ctx-room-2", "session-2")
+
+ await store.clear_current_session_for_context("ctx-room-1")
+
+ assert await store.get_current_session_for_context("ctx-room-1") is None
+ assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
+
+
+@pytest.mark.asyncio
+async def test_saved_sessions_remain_user_scoped_separate_from_live_context_state():
+ store = PrototypeStateStore()
+
+ await store.set_current_session_for_context("ctx-room-1", "room-session")
+ await store.set_last_tokens_used_for_context("ctx-room-1", 77)
+ await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
+
+ sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
+
+ assert [session["name"] for session in sessions] == ["alpha"]
+ assert all(isinstance(session["created_at"], str) for session in sessions)
+ assert await store.get_current_session_for_context("ctx-room-1") == "room-session"
+ assert await store.get_last_tokens_used_for_context("ctx-room-1") == 77
diff --git a/tests/platform/test_real.py b/tests/platform/test_real.py
new file mode 100644
index 0000000..8bce30b
--- /dev/null
+++ b/tests/platform/test_real.py
@@ -0,0 +1,465 @@
+import asyncio
+
+import pytest
+from pydantic import Field
+
+from core.protocol import SettingsAction
+from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformError, UserSettings
+from sdk.prototype_state import PrototypeStateStore
+from sdk.real import RealPlatformClient
+from sdk.upstream_agent_api import MsgEventSendFile, MsgEventTextChunk
+
+
+class FakeChatAgentApi:
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ self.agent_id = agent_id
+ self.base_url = base_url
+ self.chat_id = str(chat_id)
+ self.calls: list[str] = []
+ self.connect_calls = 0
+ self.close_calls = 0
+
+ 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, attachments: list[str] | None = None):
+ self.calls.append(text)
+ midpoint = len(text) // 2
+ yield MsgEventTextChunk(text=text[:midpoint])
+ yield MsgEventTextChunk(text=text[midpoint:])
+
+
+class FakeAgentApiFactory:
+ def __init__(self, chat_api_cls=FakeChatAgentApi) -> None:
+ self.chat_api_cls = chat_api_cls
+ self.created_calls: list[tuple[str, str, str]] = []
+ self.instances_by_chat: dict[str, list[FakeChatAgentApi]] = {}
+
+ def __call__(self, agent_id: str, base_url: str, chat_id: str):
+ chat_key = str(chat_id)
+ chat_api = self.chat_api_cls(agent_id, base_url, chat_key)
+ self.created_calls.append((agent_id, base_url, chat_key))
+ self.instances_by_chat.setdefault(chat_key, []).append(chat_api)
+ return chat_api
+
+ def latest(self, chat_id: str):
+ return self.instances_by_chat[str(chat_id)][-1]
+
+
+class BlockingTracker:
+ def __init__(self) -> None:
+ self.active_calls = 0
+ self.max_active_calls = 0
+ self.started = asyncio.Event()
+ self.release = asyncio.Event()
+
+
+class BlockingChatAgentApi(FakeChatAgentApi):
+ def __init__(
+ self,
+ agent_id: str,
+ base_url: str,
+ chat_id: str,
+ *,
+ tracker: BlockingTracker,
+ ) -> None:
+ super().__init__(agent_id, base_url, chat_id)
+ self._tracker = tracker
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append(text)
+ self._tracker.active_calls += 1
+ self._tracker.max_active_calls = max(
+ self._tracker.max_active_calls,
+ self._tracker.active_calls,
+ )
+ self._tracker.started.set()
+ await self._tracker.release.wait()
+ self._tracker.active_calls -= 1
+ yield MsgEventTextChunk(text=text)
+
+
+class BlockingAgentApiFactory(FakeAgentApiFactory):
+ def __init__(self) -> None:
+ super().__init__()
+ self.tracker = BlockingTracker()
+
+ def __call__(self, agent_id: str, base_url: str, chat_id: str):
+ chat_key = str(chat_id)
+ chat_api = BlockingChatAgentApi(
+ agent_id,
+ base_url,
+ chat_key,
+ tracker=self.tracker,
+ )
+ self.created_calls.append((agent_id, base_url, chat_key))
+ self.instances_by_chat.setdefault(chat_key, []).append(chat_api)
+ return chat_api
+
+
+class AttachmentTrackingChatAgentApi(FakeChatAgentApi):
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ super().__init__(agent_id, base_url, chat_id)
+ self.calls: list[tuple[str, list[str] | None]] = []
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments))
+ yield MsgEventTextChunk(text=text)
+
+
+class FlakyChatAgentApi(FakeChatAgentApi):
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ raise ConnectionError("Connection closed")
+ yield
+
+
+class ReuseSensitiveChatAgentApi(FakeChatAgentApi):
+ def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
+ super().__init__(agent_id, base_url, chat_id)
+ self._send_calls = 0
+
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append(text)
+ self._send_calls += 1
+ if text == "first":
+ yield MsgEventTextChunk(text="tool ok")
+ return
+ if text == "second" and self._send_calls == 1:
+ yield MsgEventTextChunk(text="Missing")
+
+
+class MessageResponseWithAttachments(MessageResponse):
+ attachments: list[Attachment] = Field(default_factory=list)
+
+
+def make_real_platform_client(
+ agent_api_cls,
+ *,
+ prototype_state: PrototypeStateStore | None = None,
+) -> RealPlatformClient:
+ return RealPlatformClient(
+ agent_id="matrix-bot",
+ agent_base_url="http://platform-agent:8000",
+ agent_api_cls=agent_api_cls,
+ prototype_state=prototype_state or PrototypeStateStore(),
+ platform="matrix",
+ )
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_get_or_create_user_uses_local_state():
+ client = make_real_platform_client(FakeAgentApiFactory())
+
+ first = await client.get_or_create_user("u1", "matrix", "Alice")
+ second = await client.get_or_create_user("u1", "matrix")
+
+ assert first.user_id == "usr-matrix-u1"
+ assert first.is_new is True
+ assert second.user_id == first.user_id
+ assert second.is_new is False
+ assert second.display_name == "Alice"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_send_message_uses_direct_agent_api_per_chat():
+ agent_api = FakeAgentApiFactory()
+ prototype_state = PrototypeStateStore()
+ client = make_real_platform_client(agent_api, prototype_state=prototype_state)
+
+ result = await client.send_message("@alice:example.org", "chat-7", "hello")
+
+ assert result == MessageResponse(
+ message_id="@alice:example.org",
+ response="hello",
+ tokens_used=0,
+ finished=True,
+ )
+ assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-7")]
+ assert agent_api.latest("chat-7").chat_id == "chat-7"
+ assert agent_api.latest("chat-7").calls == ["hello"]
+ assert agent_api.latest("chat-7").connect_calls == 1
+ assert agent_api.latest("chat-7").close_calls == 1
+ assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_preserves_path_base_url_without_trailing_slash():
+ agent_api = FakeAgentApiFactory()
+ client = RealPlatformClient(
+ agent_id="agent-17",
+ agent_base_url="http://lambda.coredump.ru:7000/agent_17",
+ agent_api_cls=agent_api,
+ prototype_state=PrototypeStateStore(),
+ platform="matrix",
+ )
+
+ await client.send_message("@alice:example.org", "41", "hello")
+
+ assert agent_api.created_calls == [
+ ("agent-17", "http://lambda.coredump.ru:7000/agent_17/", "41")
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_forwards_attachments_to_chat_api():
+ agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi)
+ client = make_real_platform_client(agent_api)
+ attachment = Attachment(
+ url="/agents/7/surfaces/matrix/alice/room/inbox/report.pdf",
+ workspace_path="surfaces/matrix/alice/room/inbox/report.pdf",
+ mime_type="application/pdf",
+ filename="report.pdf",
+ size=123,
+ )
+
+ result = await client.send_message(
+ "@alice:example.org",
+ "chat-7",
+ "hello",
+ attachments=[attachment],
+ )
+
+ assert agent_api.latest("chat-7").calls == [
+ ("hello", ["surfaces/matrix/alice/room/inbox/report.pdf"])
+ ]
+ assert result.response == "hello"
+ assert result.tokens_used == 0
+
+
+def test_attachment_paths_normalize_workspace_roots_to_relative_paths():
+ attachments = [
+ Attachment(workspace_path="/workspace/report.pdf"),
+ Attachment(workspace_path="/agents/7/report.csv"),
+ Attachment(workspace_path="note.txt"),
+ ]
+
+ assert RealPlatformClient._attachment_paths(attachments) == [
+ "report.pdf",
+ "report.csv",
+ "note.txt",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch):
+ class FileEventAgentApi(AttachmentTrackingChatAgentApi):
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ self.calls.append((text, attachments))
+ yield MsgEventTextChunk(text="he")
+ yield MsgEventSendFile(path="report.pdf")
+ yield MsgEventTextChunk(text="llo")
+
+ agent_api = FakeAgentApiFactory(chat_api_cls=FileEventAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ monkeypatch.setattr("sdk.real.MessageResponse", MessageResponseWithAttachments)
+
+ result = await client.send_message("@alice:example.org", "chat-7", "hello")
+
+ assert result.response == "hello"
+ assert result.tokens_used == 0
+ assert result.attachments == [
+ Attachment(
+ url="report.pdf",
+ mime_type="application/octet-stream",
+ filename="report.pdf",
+ size=None,
+ workspace_path="report.pdf",
+ )
+ ]
+
+
+@pytest.mark.parametrize(
+ ("location", "expected_workspace_path"),
+ [
+ ("/workspace/report.pdf", "report.pdf"),
+ ("/agents/7/report.pdf", "report.pdf"),
+ (
+ "surfaces/matrix/alice/room/inbox/report.pdf",
+ "surfaces/matrix/alice/room/inbox/report.pdf",
+ ),
+ ],
+)
+def test_attachment_from_send_file_event_normalizes_shared_volume_paths(
+ location: str, expected_workspace_path: str
+):
+ attachment = RealPlatformClient._attachment_from_send_file_event(
+ MsgEventSendFile(path=location)
+ )
+
+ assert attachment.url == location
+ assert attachment.workspace_path == expected_workspace_path
+ assert attachment.filename == "report.pdf"
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_uses_fresh_agent_connection_per_request():
+ agent_api = FakeAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+ await client.send_message("@alice:example.org", "chat-1", "again")
+
+ assert agent_api.created_calls == [
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ]
+ assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [
+ ["hello"],
+ ["again"],
+ ]
+ assert all(instance.connect_calls == 1 for instance in agent_api.instances_by_chat["chat-1"])
+ assert all(instance.close_calls == 1 for instance in agent_api.instances_by_chat["chat-1"])
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_avoids_reuse_sensitive_second_message_loss():
+ agent_api = FakeAgentApiFactory(chat_api_cls=ReuseSensitiveChatAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ first = await client.send_message("@alice:example.org", "chat-1", "first")
+ second = await client.send_message("@alice:example.org", "chat-1", "second")
+
+ assert first.response == "tool ok"
+ assert second.response == "Missing"
+ assert len(agent_api.instances_by_chat["chat-1"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_wraps_connection_closed_as_platform_error():
+ agent_api = FakeAgentApiFactory(chat_api_cls=FlakyChatAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ with pytest.raises(PlatformError, match="Connection closed") as exc_info:
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+
+ assert exc_info.value.code == "PLATFORM_CONNECTION_ERROR"
+ assert agent_api.latest("chat-1").close_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_uses_fresh_connection_after_failure():
+ class SometimesFlakyAgentApi(FakeChatAgentApi):
+ async def send_message(self, text: str, attachments: list[str] | None = None):
+ if text == "hello":
+ raise ConnectionError("Connection closed")
+ self.calls.append(text)
+ yield MsgEventTextChunk(text=text)
+
+ agent_api = FakeAgentApiFactory(chat_api_cls=SometimesFlakyAgentApi)
+ client = make_real_platform_client(agent_api)
+
+ with pytest.raises(PlatformError, match="Connection closed"):
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+
+ result = await client.send_message("@alice:example.org", "chat-1", "again")
+
+ assert result.response == "again"
+ assert agent_api.created_calls == [
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ]
+ assert agent_api.latest("chat-1").calls == ["again"]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_serializes_same_chat_streams_across_send_paths():
+ agent_api = BlockingAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ 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.tracker.started.wait(), timeout=1)
+
+ send_task = asyncio.create_task(client.send_message("@alice:example.org", "chat-1", "again"))
+ await asyncio.sleep(0)
+
+ assert len(agent_api.instances_by_chat["chat-1"]) == 1
+ assert agent_api.instances_by_chat["chat-1"][0].calls == ["hello"]
+ assert agent_api.tracker.max_active_calls == 1
+
+ agent_api.tracker.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 [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [
+ ["hello"],
+ ["again"],
+ ]
+ assert agent_api.tracker.max_active_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_creates_distinct_connections_per_chat():
+ agent_api = FakeAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ await client.send_message("@alice:example.org", "chat-1", "hello")
+ await client.send_message("@alice:example.org", "chat-2", "world")
+
+ assert agent_api.created_calls == [
+ ("matrix-bot", "http://platform-agent:8000", "chat-1"),
+ ("matrix-bot", "http://platform-agent:8000", "chat-2"),
+ ]
+ assert agent_api.latest("chat-1").calls == ["hello"]
+ assert agent_api.latest("chat-2").calls == ["world"]
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
+ agent_api = FakeAgentApiFactory()
+ client = make_real_platform_client(agent_api)
+
+ chunks = []
+ async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"):
+ chunks.append(chunk)
+
+ assert chunks == [
+ MessageChunk(
+ message_id="@alice:example.org",
+ delta="he",
+ finished=False,
+ tokens_used=0,
+ ),
+ MessageChunk(
+ message_id="@alice:example.org",
+ delta="llo",
+ finished=False,
+ tokens_used=0,
+ ),
+ MessageChunk(
+ message_id="@alice:example.org",
+ delta="",
+ finished=True,
+ tokens_used=0,
+ ),
+ ]
+ assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-1")]
+ assert agent_api.latest("chat-1").calls == ["hello"]
+ assert agent_api.latest("chat-1").close_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_real_platform_client_settings_are_local():
+ client = make_real_platform_client(FakeAgentApiFactory())
+
+ await client.update_settings(
+ "usr-matrix-u1",
+ SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
+ )
+
+ settings = await client.get_settings("usr-matrix-u1")
+
+ assert isinstance(settings, UserSettings)
+ assert settings.skills["browser"] is True
+ assert settings.skills["web-search"] is True
diff --git a/tests/test_check_matrix_agents.py b/tests/test_check_matrix_agents.py
new file mode 100644
index 0000000..25f63bd
--- /dev/null
+++ b/tests/test_check_matrix_agents.py
@@ -0,0 +1,22 @@
+from tools.check_matrix_agents import build_agent_ws_url
+
+
+def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash():
+ assert (
+ build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41")
+ == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
+ )
+
+
+def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash():
+ assert (
+ build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41")
+ == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
+ )
+
+
+def test_build_agent_ws_url_accepts_existing_agent_ws_url():
+ assert (
+ build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41")
+ == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
+ )
diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py
new file mode 100644
index 0000000..0cf2057
--- /dev/null
+++ b/tests/test_deploy_handoff.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import yaml
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def _compose(path: str) -> dict:
+ return yaml.safe_load((ROOT / path).read_text(encoding="utf-8"))
+
+
+def test_prod_compose_uses_registry_image_not_local_build():
+ prod = _compose("docker-compose.prod.yml")
+ service = prod["services"]["matrix-bot"]
+
+ assert "image" in service
+ assert "build" not in service
+ assert service["image"].startswith("${SURFACES_BOT_IMAGE:?")
+
+
+def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context():
+ fullstack = _compose("docker-compose.fullstack.yml")
+ service = fullstack["services"]["matrix-bot"]
+
+ assert service["build"]["target"] == "development"
+ assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api"
+ assert service["extends"]["file"] == "docker-compose.prod.yml"
+
+
+def test_dockerfile_production_build_does_not_require_local_external_tree():
+ dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
+
+ assert "/app/external/platform-agent_api" not in dockerfile
+ assert "external/platform-agent_api" not in dockerfile
+ assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile
+ assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile
+ assert "uv pip install --system --ignore-requires-python" not in dockerfile
+
+
+def test_dockerfile_installs_agent_api_after_final_uv_sync():
+ dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
+ development = dockerfile.split("FROM base AS development", maxsplit=1)[1].split(
+ "FROM base AS production", maxsplit=1
+ )[0]
+ production = dockerfile.split("FROM base AS production", maxsplit=1)[1]
+
+ assert development.index("RUN uv sync --no-dev --frozen") < development.index(
+ "pip install --no-cache-dir --ignore-requires-python -e /agent_api/"
+ )
+ assert production.index("RUN uv sync --no-dev --frozen") < production.index(
+ "git+https://git.lambda.coredump.ru/platform/agent_api.git"
+ )
+
+
+def test_dockerignore_excludes_local_only_and_runtime_artifacts():
+ dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
+
+ assert "external/" in dockerignore
+ assert ".planning/" in dockerignore
+ assert "config/matrix-agents.yaml" in dockerignore
+ assert ".env" in dockerignore
+
+
+def test_agent_registry_example_documents_multi_agent_volume_contract():
+ registry = yaml.safe_load(
+ (ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8")
+ )
+ agents = registry["agents"]
+
+ assert len(agents) >= 3
+ assert len({agent["id"] for agent in agents}) == len(agents)
+ assert len({agent["workspace_path"] for agent in agents}) == len(agents)
+ for index, agent in enumerate(agents):
+ assert agent["base_url"].endswith(f"/agent_{index}/")
+ assert agent["workspace_path"] == f"/agents/{index}"
+
+
+def test_smoke_compose_models_deploy_like_proxy_and_surface_checker():
+ smoke = _compose("docker-compose.smoke.yml")
+
+ assert set(smoke["services"]) >= {"surface-smoke", "agent-proxy", "agent-0", "agent-1"}
+ assert "tools.check_matrix_agents" in smoke["services"]["surface-smoke"]["command"]
+ assert smoke["services"]["agent-proxy"]["ports"] == ["${SMOKE_PROXY_PORT:-7000}:7000"]
+
+
+def test_smoke_timeout_override_routes_one_agent_to_no_status_stub():
+ smoke_timeout = _compose("docker-compose.smoke.timeout.yml")
+
+ assert set(smoke_timeout["services"]) >= {"agent-proxy", "agent-no-status"}
+
+
+def test_smoke_registry_targets_local_proxy_routes():
+ registry = yaml.safe_load(
+ (ROOT / "config" / "matrix-agents.smoke.yaml").read_text(encoding="utf-8")
+ )
+
+ assert [agent["base_url"] for agent in registry["agents"]] == [
+ "http://agent-proxy:7000/agent_0/",
+ "http://agent-proxy:7000/agent_1/",
+ ]
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..a1d9c25
--- /dev/null
+++ b/tools/__init__.py
@@ -0,0 +1 @@
+"""Operational tools for surfaces-bot."""
diff --git a/tools/check_matrix_agents.py b/tools/check_matrix_agents.py
new file mode 100644
index 0000000..d6035aa
--- /dev/null
+++ b/tools/check_matrix_agents.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from urllib.parse import urljoin
+
+import aiohttp
+
+from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry
+from sdk.real import RealPlatformClient
+
+
+@dataclass
+class AgentCheckResult:
+ agent_id: str
+ label: str
+ chat_id: str
+ base_url: str
+ ws_url: str
+ ok: bool
+ stage: str
+ latency_ms: int
+ error: str = ""
+ response_type: str = ""
+
+
+def build_agent_ws_url(base_url: str, chat_id: str) -> str:
+ normalized = RealPlatformClient._normalize_agent_base_url(base_url)
+ return urljoin(normalized, f"v1/agent_ws/{chat_id}/")
+
+
+def _message_type(payload: str) -> str:
+ try:
+ data = json.loads(payload)
+ except json.JSONDecodeError:
+ return ""
+ value = data.get("type")
+ return value if isinstance(value, str) else ""
+
+
+async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str:
+ msg = await asyncio.wait_for(ws.receive(), timeout=timeout)
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ return str(msg.data)
+ if msg.type == aiohttp.WSMsgType.ERROR:
+ raise RuntimeError(f"websocket error: {ws.exception()}")
+ raise RuntimeError(f"unexpected websocket message type: {msg.type.name}")
+
+
+async def check_agent(
+ agent: AgentDefinition,
+ *,
+ fallback_base_url: str,
+ chat_id: str,
+ timeout: float,
+ message: str | None,
+) -> AgentCheckResult:
+ base_url = agent.base_url or fallback_base_url
+ ws_url = build_agent_ws_url(base_url, chat_id) if base_url else ""
+ started = time.perf_counter()
+
+ def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult:
+ return AgentCheckResult(
+ agent_id=agent.agent_id,
+ label=agent.label,
+ chat_id=chat_id,
+ base_url=base_url,
+ ws_url=ws_url,
+ ok=ok,
+ stage=stage,
+ latency_ms=int((time.perf_counter() - started) * 1000),
+ error=error,
+ response_type=response_type,
+ )
+
+ if not base_url:
+ return result(False, "config", "missing base_url and AGENT_BASE_URL")
+
+ try:
+ client_timeout = aiohttp.ClientTimeout(
+ total=timeout,
+ connect=timeout,
+ sock_connect=timeout,
+ sock_read=timeout,
+ )
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.ws_connect(ws_url, heartbeat=30) as ws:
+ raw_status = await _receive_text(ws, timeout)
+ status_type = _message_type(raw_status)
+ if status_type != "STATUS":
+ return result(
+ False,
+ "status",
+ f"expected STATUS, got {raw_status[:200]}",
+ status_type,
+ )
+
+ if not message:
+ return result(True, "status", response_type=status_type)
+
+ payload = {
+ "type": "USER_MESSAGE",
+ "text": message,
+ "attachments": [],
+ }
+ await ws.send_str(json.dumps(payload))
+
+ while True:
+ raw_event = await _receive_text(ws, timeout)
+ event_type = _message_type(raw_event)
+ if event_type == "ERROR":
+ return result(False, "message", raw_event[:200], event_type)
+ if event_type == "AGENT_EVENT_END":
+ return result(True, "message", response_type=event_type)
+ if not event_type:
+ return result(False, "message", f"invalid JSON event: {raw_event[:200]}")
+ except TimeoutError:
+ return result(False, "timeout", f"no response within {timeout:g}s")
+ except Exception as exc:
+ return result(False, "connect", str(exc))
+
+
+def _select_agents(
+ agents: tuple[AgentDefinition, ...],
+ selected: set[str],
+) -> list[AgentDefinition]:
+ if not selected:
+ return list(agents)
+ return [agent for agent in agents if agent.agent_id in selected]
+
+
+async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]:
+ registry = load_agent_registry(args.config)
+ selected = _select_agents(registry.agents, set(args.agent))
+ if not selected:
+ raise SystemExit("no matching agents selected")
+
+ fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "")
+ semaphore = asyncio.Semaphore(args.concurrency)
+
+ async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult:
+ chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index)
+ async with semaphore:
+ return await check_agent(
+ agent,
+ fallback_base_url=fallback_base_url,
+ chat_id=chat_id,
+ timeout=args.timeout,
+ message=args.message,
+ )
+
+ return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected)))
+
+
+def print_table(results: list[AgentCheckResult]) -> None:
+ for item in results:
+ status = "OK" if item.ok else "FAIL"
+ detail = item.response_type or item.error
+ print(
+ f"{status:4} {item.agent_id:20} {item.stage:8} "
+ f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}"
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml."
+ )
+ parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml"))
+ parser.add_argument("--agent", action="append", default=[], help="Agent id to check")
+ parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none")
+ parser.add_argument("--timeout", type=float, default=10.0)
+ parser.add_argument("--concurrency", type=int, default=5)
+ parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id")
+ parser.add_argument("--chat-id-base", type=int, default=900000)
+ parser.add_argument("--message", default=None, help="Optional test message after STATUS")
+ parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ results = asyncio.run(run_checks(args))
+ if args.json:
+ print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2))
+ else:
+ print_table(results)
+ return 0 if all(result.ok for result in results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/no_status_agent.py b/tools/no_status_agent.py
new file mode 100644
index 0000000..adb563a
--- /dev/null
+++ b/tools/no_status_agent.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+
+from aiohttp import web
+
+
+async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ await asyncio.sleep(3600)
+ return ws
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="WebSocket stub that accepts connections but sends no STATUS."
+ )
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8000)
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ app = web.Application()
+ app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler)
+ web.run_app(app, host=args.host, port=args.port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
index 0c37403..76a9426 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1095,6 +1095,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
+[[package]]
+name = "pytest-aiohttp"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
+]
+
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
@@ -1140,6 +1154,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" },
]
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
[[package]]
name = "referencing"
version = "0.37.0"
@@ -1302,10 +1371,12 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiogram" },
+ { name = "aiohttp" },
{ name = "httpx" },
{ name = "matrix-nio" },
{ name = "pydantic" },
{ name = "python-dotenv" },
+ { name = "pyyaml" },
{ name = "structlog" },
]
@@ -1313,6 +1384,7 @@ dependencies = [
dev = [
{ name = "mypy" },
{ name = "pytest" },
+ { name = "pytest-aiohttp" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
@@ -1321,14 +1393,17 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.4,<4" },
+ { name = "aiohttp", specifier = ">=3.9" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "matrix-nio", specifier = ">=0.21" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" },
{ name = "pydantic", specifier = ">=2.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "pytest-aiohttp", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "python-dotenv", specifier = ">=1.0" },
+ { name = "pyyaml", specifier = ">=6.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
{ name = "structlog", specifier = ">=24.1" },
]