feat: finalize matrix platform audit and docs

This commit is contained in:
Mikhail Putilovskij 2026-04-21 15:35:03 +03:00
parent 6422c7db58
commit 4524a6abc8
30 changed files with 3093 additions and 176 deletions

View file

@ -11,7 +11,7 @@ MATRIX_PLATFORM_BACKEND=real
SURFACES_WORKSPACE_DIR=/workspace SURFACES_WORKSPACE_DIR=/workspace
# Compose-local platform-agent route # Compose-local platform-agent route
AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/{chat_id}/ AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/
AGENT_BASE_URL=http://platform-agent:8000 AGENT_BASE_URL=http://platform-agent:8000
# platform-agent provider # platform-agent provider

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ build/
# Git worktrees (не трекаем в репо) # Git worktrees (не трекаем в репо)
.worktrees/ .worktrees/
external/
# IDE # IDE
.idea/ .idea/

View file

@ -3,11 +3,11 @@ phase: 01.1-matrix-restart-reconciliation-and-dev-reset-workflow
task: 1 task: 1
total_tasks: 2 total_tasks: 2
status: paused status: paused
last_updated: 2026-04-07T15:11:42.203Z last_updated: 2026-04-07T21:29:48.982Z
--- ---
<current_state> <current_state>
Formally, the most recently active execution artifact is still `01.1-03-PLAN.md`, which has not been implemented yet. Since the earlier checkpoint, a fresh live review of the platform repos confirmed that `master` still does not provide a usable consumer-facing control-plane API, but a working Matrix prototype is now feasible by talking directly to the `agent` WebSocket through a new `sdk/real.py` compatibility shim. 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.
</current_state> </current_state>
<completed_work> <completed_work>
@ -16,16 +16,19 @@ Formally, the most recently active execution artifact is still `01.1-03-PLAN.md`
- Confirmed `master` is still only a thin HTTP skeleton with `/health` and `/users/{user_id}`, not a chat/session/settings backend for surfaces. - 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. - 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. - 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 the recommended prototype path: keep Matrix adapter logic largely intact, add a new `sdk/real.py` shim for direct agent communication, and ask the platform team for a minimal agent-side change to support per-chat thread identity. - 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.
- Started a product/architecture discussion about where that prototype should live: in this repo as the first real backend path, or in a separate repo as a Matrix-only spike. The user asked to save the session before answering that design question. - 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`.
</completed_work> </completed_work>
<remaining_work> <remaining_work>
- Task 1: Implement `adapter.matrix.reset` with `local-only`, `server-leave-forget`, and `--dry-run`, plus tests. - 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. - Task 2: Update `README.md` so restart vs explicit reset workflow is documented and the old manual reset ritual is removed.
- Design follow-up: decide whether the direct-agent prototype belongs in this repo or a separate repo. - Prototype evaluation follow-up: review the approved spec and plan against the platform repos before starting execution.
- Future prototype work, once design is approved: introduce `sdk/real.py` plus a narrow compatibility boundary that keeps Matrix adapter logic stable while allowing later expansion toward a fuller platform split. - 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.
</remaining_work> </remaining_work>
<decisions_made> <decisions_made>
@ -34,6 +37,8 @@ Formally, the most recently active execution artifact is still `01.1-03-PLAN.md`
- Use the direct `agent` WebSocket as the only realistic path for a working prototype right now. - 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/`. - 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. - 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.
</decisions_made> </decisions_made>
<blockers> <blockers>
@ -43,12 +48,16 @@ Formally, the most recently active execution artifact is still `01.1-03-PLAN.md`
</blockers> </blockers>
<context> <context>
The important mental model changed slightly since the earlier checkpoint. Before, the conclusion was mainly “Phase 02 is blocked because the platform contract is unstable.” That is still true for full SDK integration through `master`, but it is no longer the whole story. There is now a practical bridge strategy: use the existing `agent` WebSocket directly for message generation, keep settings/user mapping local for the prototype, and preserve adapter stability by hiding all of this behind a new `sdk/real.py` implementation. The open architecture decision is repo placement: short-lived prototype repo versus building the first durable real-backend path here. 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`
</context> </context>
<next_action> <next_action>
Resume with one of these depending on priority: Resume with one of these depending on priority:
1. If continuing phase execution, implement `01.1-03-PLAN.md` Task 1 (`adapter.matrix.reset`) first. 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 continuing platform design, answer the pending repo-placement question: keep the prototype in this repo or create a separate repo for a Matrix-only spike. 2. If staying on roadmap execution, implement `01.1-03-PLAN.md` Task 1 (`adapter.matrix.reset`) first.
3. After that decision, write the design for the direct-agent shim path before touching code. 3. If starting prototype execution immediately, begin with Task 1 of `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`.
</next_action> </next_action>

View file

@ -1,6 +1,6 @@
# Thread: Matrix dev prototype — состояние агента и платформы # Thread: Matrix dev prototype — состояние агента и платформы
## Status: OPEN ## Status: IN PROGRESS
## Goal ## Goal

View file

@ -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 всё ещё не подтверждён.

View file

@ -68,7 +68,7 @@ surfaces-bot/
- **Чаты**`!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager` - **Чаты**`!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager`
- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher` - **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher`
- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта - **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
- **Текущее ограничение** — encrypted DM пока не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота - **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
- **Backend selection**`MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и WebSocket contract `/v1/agent_ws/{chat_id}/` - **Backend selection**`MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и WebSocket contract `/v1/agent_ws/{chat_id}/`
- **Ограничения real backend** — локальный runtime использует shared `/workspace`, а файлы передаются как относительные пути в `attachments` - **Ограничения real backend** — локальный runtime использует shared `/workspace`, а файлы передаются как относительные пути в `attachments`
@ -125,6 +125,11 @@ MATRIX_PLATFORM_BACKEND=real
AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/ AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/
AGENT_BASE_URL=http://platform-agent:8000 AGENT_BASE_URL=http://platform-agent:8000
SURFACES_WORKSPACE_DIR=/workspace SURFACES_WORKSPACE_DIR=/workspace
# platform-agent provider
PROVIDER_MODEL=openai/gpt-4o-mini
PROVIDER_URL=https://openrouter.ai/api/v1
PROVIDER_API_KEY=...
``` ```
### 3. Compose runtime ### 3. Compose runtime
@ -141,7 +146,12 @@ Compose собирает `platform-agent` из актуального upstream `
с правами для agent runtime. с правами для agent runtime.
Matrix бот подключается к `platform-agent` по service name, а не к отдельно запущенному `localhost`. Matrix бот подключается к `platform-agent` по service name, а не к отдельно запущенному `localhost`.
### 4.1. Staged attachments в Matrix На `2026-04-21` локальный compose runtime использует vendored upstream-версии платформы без локальных патчей:
- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
### 4. Staged attachments в Matrix
Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу. Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу.
Вместо этого он сохраняет файлы в shared `/workspace`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения. Вместо этого он сохраняет файлы в shared `/workspace`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения.
@ -154,7 +164,7 @@ Matrix бот подключается к `platform-agent` по service name, а
Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами. Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами.
### 4. Запуск бота вручную ### 5. Запуск бота вручную
```bash ```bash
# Первый запуск или сброс состояния # Первый запуск или сброс состояния
@ -163,9 +173,9 @@ rm -f lambda_matrix.db && rm -rf matrix_store
PYTHONPATH=. uv run python -m adapter.matrix.bot PYTHONPATH=. uv run python -m adapter.matrix.bot
``` ```
### 5. Онбординг пользователя ### 6. Онбординг пользователя
Напиши боту в **личные сообщения (DM)** на Matrix-сервере. Шифрование не требуется — бот работает в незашифрованных комнатах (на нашем сервере работает и в зашифрованных DM). Напиши боту в **личные сообщения (DM)** на Matrix-сервере. Для поддерживаемого dev-сценария используй незашифрованную комнату: E2EE сейчас не считается поддержанным режимом для Matrix-поверхности.
Бот автоматически: Бот автоматически:
1. Создаст private Space `Lambda — {твоё имя}` 1. Создаст private Space `Lambda — {твоё имя}`
@ -187,7 +197,7 @@ PYTHONPATH=. uv run python -m adapter.matrix.bot
| Переименование | `!rename <название>` | | | Переименование | `!rename <название>` | |
| Архивация | `!archive` | | | Архивация | `!archive` | |
| Диалог с агентом | *(любое сообщение)* | Стриминг ответа через WebSocket | | Диалог с агентом | *(любое сообщение)* | Стриминг ответа через WebSocket |
| Изоляция контекста | *(автоматически)* | Каждая комната — отдельный thread_id агента | | Изоляция контекста | *(автоматически)* | Каждая комната получает отдельный `platform_chat_id` |
| Сохранение контекста | `!save [имя]` | Агент сохраняет краткое резюме разговора | | Сохранение контекста | `!save [имя]` | Агент сохраняет краткое резюме разговора |
| Список сохранений | `!load` | Выбор по номеру | | Список сохранений | `!load` | Выбор по номеру |
| Состояние контекста | `!context` | Текущая сессия и список сохранений | | Состояние контекста | `!context` | Текущая сессия и список сохранений |

View file

@ -41,6 +41,7 @@ from adapter.matrix.store import (
get_load_pending, get_load_pending,
get_room_meta, get_room_meta,
get_staged_attachments, get_staged_attachments,
next_platform_chat_id,
remove_staged_attachment_at, remove_staged_attachment_at,
set_pending_confirm, set_pending_confirm,
set_platform_chat_id, set_platform_chat_id,
@ -163,7 +164,11 @@ class MatrixBot:
return return
if room_meta.get("platform_chat_id"): if room_meta.get("platform_chat_id"):
return return
await set_platform_chat_id(self.runtime.store, room_id, f"matrix:{room_id}") await set_platform_chat_id(
self.runtime.store,
room_id,
await next_platform_chat_id(self.runtime.store),
)
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None: async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if getattr(event, "sender", None) == self.client.user_id: if getattr(event, "sender", None) == self.client.user_id:

View file

@ -41,12 +41,7 @@ def build_workspace_attachment_path(
safe_room = _sanitize_component(room_id.lstrip("!")) safe_room = _sanitize_component(room_id.lstrip("!"))
safe_name = _sanitize_component(filename) or "attachment.bin" safe_name = _sanitize_component(filename) or "attachment.bin"
relative_path = ( relative_path = (
Path("surfaces") Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
/ "matrix"
/ safe_user
/ safe_room
/ "inbox"
/ f"{stamp}-{safe_name}"
) )
return relative_path.as_posix(), workspace_root / relative_path return relative_path.as_posix(), workspace_root / relative_path

View file

@ -17,7 +17,6 @@ from adapter.matrix.handlers.settings import (
handle_help, handle_help,
handle_settings, handle_settings,
handle_settings_connectors, handle_settings_connectors,
handle_unknown_command,
handle_settings_plan, handle_settings_plan,
handle_settings_safety, handle_settings_safety,
handle_settings_skills, handle_settings_skills,
@ -25,6 +24,7 @@ from adapter.matrix.handlers.settings import (
handle_settings_status, handle_settings_status,
handle_settings_whoami, handle_settings_whoami,
handle_toggle_skill, handle_toggle_skill,
handle_unknown_command,
) )
from core.handler import EventDispatcher from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand from core.protocol import IncomingCallback, IncomingCommand
@ -44,7 +44,13 @@ def register_matrix_handlers(
dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store))
dispatcher.register(IncomingCommand, "help", handle_help) dispatcher.register(IncomingCommand, "help", handle_help)
dispatcher.register(IncomingCommand, "settings", handle_settings) dispatcher.register(IncomingCommand, "settings", handle_settings)
dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, prototype_state) if prototype_state is not None else handle_settings) dispatcher.register(
IncomingCommand,
"reset",
make_handle_reset(store, prototype_state)
if prototype_state is not None
else handle_settings,
)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills) dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors) dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)
dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul) dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul)
@ -59,6 +65,10 @@ def register_matrix_handlers(
dispatcher.register(IncomingCommand, "*", handle_unknown_command) dispatcher.register(IncomingCommand, "*", handle_unknown_command)
if agent_api is not None and prototype_state is not None: 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,
"save",
make_handle_save(agent_api, store, prototype_state),
)
dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state)) dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state))
dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state)) dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state))

View file

@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
import structlog
from typing import Any from typing import Any
import structlog
from nio.api import RoomVisibility from nio.api import RoomVisibility
from nio.responses import RoomCreateError from nio.responses import RoomCreateError
from adapter.matrix.store import ( from adapter.matrix.store import (
get_user_meta, get_user_meta,
next_chat_id, next_platform_chat_id,
set_room_meta, set_room_meta,
set_user_meta, set_user_meta,
) )
@ -62,6 +62,7 @@ async def provision_workspace_chat(
next_chat_index = int(user_meta.get("next_chat_index", 1)) next_chat_index = int(user_meta.get("next_chat_index", 1))
chat_id = f"C{next_chat_index}" 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) room_name = room_name_override or _default_room_name(chat_id)
chat_resp = await client.room_create( chat_resp = await client.room_create(
name=room_name, name=room_name,
@ -98,7 +99,7 @@ async def provision_workspace_chat(
"display_name": room_name, "display_name": room_name,
"matrix_user_id": matrix_user_id, "matrix_user_id": matrix_user_id,
"space_id": space_id, "space_id": space_id,
"platform_chat_id": f"matrix:{chat_room_id}", "platform_chat_id": platform_chat_id,
}, },
) )
await chat_mgr.get_or_create( await chat_mgr.get_or_create(
@ -118,7 +119,15 @@ async def provision_workspace_chat(
} }
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr, chat_mgr) -> None: async def handle_invite(
client: Any,
room: Any,
event: Any,
platform,
store,
auth_mgr,
chat_mgr,
) -> None:
matrix_user_id = getattr(event, "sender", "") matrix_user_id = getattr(event, "sender", "")
display_name = getattr(room, "display_name", None) or matrix_user_id display_name = getattr(room, "display_name", None) or matrix_user_id

View file

@ -1,12 +1,18 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any
import structlog import structlog
from nio.api import RoomVisibility from nio.api import RoomVisibility
from nio.responses import RoomCreateError from nio.responses import RoomCreateError
from adapter.matrix.store import get_user_meta, next_chat_id, set_room_meta from adapter.matrix.store import (
get_user_meta,
next_chat_id,
next_platform_chat_id,
set_room_meta,
)
from core.protocol import IncomingCommand, OutgoingMessage from core.protocol import IncomingCommand, OutgoingMessage
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -69,6 +75,7 @@ def make_handle_new_chat(
name = " ".join(event.args).strip() if event.args else "" name = " ".join(event.args).strip() if event.args else ""
chat_id = await next_chat_id(store, event.user_id) 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}" room_name = name or f"Чат {chat_id}"
response = await client.room_create( response = await client.room_create(
@ -106,7 +113,7 @@ def make_handle_new_chat(
"display_name": room_name, "display_name": room_name,
"matrix_user_id": event.user_id, "matrix_user_id": event.user_id,
"space_id": space_id, "space_id": space_id,
"platform_chat_id": f"matrix:{room_id}", "platform_chat_id": platform_chat_id,
}, },
) )
ctx = await chat_mgr.get_or_create( ctx = await chat_mgr.get_or_create(
@ -151,7 +158,10 @@ def make_handle_rename(
return [ return [
OutgoingMessage( OutgoingMessage(
chat_id=event.chat_id, chat_id=event.chat_id,
text="Этот чат не найден в локальном состоянии бота. Открой зарегистрированную комнату или создай новый чат через !new.", text=(
"Этот чат не найден в локальном состоянии бота. "
"Открой зарегистрированную комнату или создай новый чат через !new."
),
) )
] ]
@ -181,7 +191,10 @@ def make_handle_archive(
return [ return [
OutgoingMessage( OutgoingMessage(
chat_id=event.chat_id, chat_id=event.chat_id,
text="Этот чат не найден в локальном состоянии бота. Создай новый чат через !new.", text=(
"Этот чат не найден в локальном состоянии бота. "
"Создай новый чат через !new."
),
) )
] ]
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id)

View file

@ -7,7 +7,12 @@ from typing import TYPE_CHECKING
import httpx import httpx
import structlog import structlog
from adapter.matrix.store import get_room_meta, set_load_pending, set_platform_chat_id 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 from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage
if TYPE_CHECKING: if TYPE_CHECKING:
@ -45,7 +50,7 @@ async def _resolve_room_id(event: IncomingCommand, chat_mgr) -> str:
async def _resolve_context_scope( async def _resolve_context_scope(
event: IncomingCommand, event: IncomingCommand,
store: "StateStore", store: StateStore,
chat_mgr, chat_mgr,
) -> tuple[str, str | None]: ) -> tuple[str, str | None]:
room_id = await _resolve_room_id(event, chat_mgr) room_id = await _resolve_room_id(event, chat_mgr)
@ -54,7 +59,7 @@ async def _resolve_context_scope(
return room_id, platform_chat_id return room_id, platform_chat_id
def make_handle_save(agent_api, store: "StateStore", prototype_state: "PrototypeStateStore"): def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore):
async def handle_save( async def handle_save(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]: ) -> list[OutgoingEvent]:
@ -96,7 +101,7 @@ def make_handle_save(agent_api, store: "StateStore", prototype_state: "Prototype
return handle_save return handle_save
def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"): def make_handle_load(store: StateStore, prototype_state: PrototypeStateStore):
async def handle_load( async def handle_load(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]: ) -> list[OutgoingEvent]:
@ -123,17 +128,15 @@ def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"
return handle_load return handle_load
def make_handle_reset(store: "StateStore", prototype_state: "PrototypeStateStore"): def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore):
async def handle_reset( async def handle_reset(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]: ) -> list[OutgoingEvent]:
import time
room_id = await _resolve_room_id(event, chat_mgr) room_id = await _resolve_room_id(event, chat_mgr)
room_meta = await get_room_meta(store, room_id) room_meta = await get_room_meta(store, room_id)
old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id
new_chat_id = f"matrix:{room_id}#{int(time.time())}" new_chat_id = await next_platform_chat_id(store)
await set_platform_chat_id(store, room_id, new_chat_id) await set_platform_chat_id(store, room_id, new_chat_id)
disconnect = getattr(platform, "disconnect_chat", None) disconnect = getattr(platform, "disconnect_chat", None)
@ -142,7 +145,12 @@ def make_handle_reset(store: "StateStore", prototype_state: "PrototypeStateStore
await prototype_state.clear_current_session(new_chat_id) await prototype_state.clear_current_session(new_chat_id)
return [OutgoingMessage(chat_id=event.chat_id, text="Контекст сброшен. Агент не помнит предыдущий разговор.")] return [
OutgoingMessage(
chat_id=event.chat_id,
text="Контекст сброшен. Агент не помнит предыдущий разговор.",
)
]
return handle_reset return handle_reset
@ -170,7 +178,7 @@ async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[Outgoi
return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")] return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")]
def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"): def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore):
async def handle_context( async def handle_context(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]: ) -> list[OutgoingEvent]:

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage from core.protocol import IncomingCommand, OutgoingMessage
HELP_TEXT = "\n".join( HELP_TEXT = "\n".join(
[ [
"Команды", "Команды",
@ -32,9 +31,7 @@ async def handle_settings(
return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_help( async def handle_help(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
return [OutgoingMessage(chat_id=event.chat_id, text=HELP_TEXT)] return [OutgoingMessage(chat_id=event.chat_id, text=HELP_TEXT)]

View file

@ -13,7 +13,9 @@ PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
LOAD_PENDING_PREFIX = "matrix_load_pending:" LOAD_PENDING_PREFIX = "matrix_load_pending:"
RESET_PENDING_PREFIX = "matrix_reset_pending:" RESET_PENDING_PREFIX = "matrix_reset_pending:"
STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:" STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"
PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
_STAGED_ATTACHMENTS_LOCKS: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary() _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: async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
@ -29,9 +31,7 @@ async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None:
return meta.get("platform_chat_id") if meta else None return meta.get("platform_chat_id") if meta else None
async def set_platform_chat_id( async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None:
store: StateStore, room_id: str, platform_chat_id: str
) -> None:
meta = dict(await get_room_meta(store, room_id) or {}) meta = dict(await get_room_meta(store, room_id) or {})
meta["platform_chat_id"] = platform_chat_id meta["platform_chat_id"] = platform_chat_id
await set_room_meta(store, room_id, meta) await set_room_meta(store, room_id, meta)
@ -71,16 +71,29 @@ async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
return f"C{index}" 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: def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str:
if room_id is None: if room_id is None:
return f"{PENDING_CONFIRM_PREFIX}{user_id}" return f"{PENDING_CONFIRM_PREFIX}{user_id}"
return f"{PENDING_CONFIRM_PREFIX}{user_id}:{room_id}" return f"{PENDING_CONFIRM_PREFIX}{user_id}:{room_id}"
async def get_pending_confirm( async def get_pending_confirm(
store: StateStore, user_id: str, room_id: str | None = None store: StateStore, user_id: str, room_id: str | None = None
) -> dict | None: ) -> dict | None:
return await store.get(_pending_confirm_key(user_id, room_id)) return await store.get(_pending_confirm_key(user_id, room_id))
async def set_pending_confirm( async def set_pending_confirm(
store: StateStore, user_id: str, room_id: str | dict, meta: dict | None = None store: StateStore, user_id: str, room_id: str | dict, meta: dict | None = None
) -> None: ) -> None:
@ -146,9 +159,7 @@ def _staged_attachments_lock(room_id: str, user_id: str) -> asyncio.Lock:
return lock return lock
async def get_staged_attachments( async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
store: StateStore, room_id: str, user_id: str
) -> list[dict]:
data = await store.get(_staged_attachments_key(room_id, user_id)) data = await store.get(_staged_attachments_key(room_id, user_id))
if not isinstance(data, dict): if not isinstance(data, dict):
return [] return []
@ -166,9 +177,7 @@ async def add_staged_attachment(
async with _staged_attachments_lock(room_id, user_id): async with _staged_attachments_lock(room_id, user_id):
attachments = await get_staged_attachments(store, room_id, user_id) attachments = await get_staged_attachments(store, room_id, user_id)
attachments.append(attachment) attachments.append(attachment)
await store.set( await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments})
_staged_attachments_key(room_id, user_id), {"attachments": attachments}
)
async def remove_staged_attachment_at( async def remove_staged_attachment_at(
@ -181,16 +190,12 @@ async def remove_staged_attachment_at(
removed = attachments.pop(index) removed = attachments.pop(index)
if attachments: if attachments:
await store.set( await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments})
_staged_attachments_key(room_id, user_id), {"attachments": attachments}
)
else: else:
await store.delete(_staged_attachments_key(room_id, user_id)) await store.delete(_staged_attachments_key(room_id, user_id))
return removed return removed
async def clear_staged_attachments( async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
store: StateStore, room_id: str, user_id: str
) -> None:
async with _staged_attachments_lock(room_id, user_id): async with _staged_attachments_lock(room_id, user_id):
await store.delete(_staged_attachments_key(room_id, user_id)) await store.delete(_staged_attachments_key(room_id, user_id))

View file

@ -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-соединения

View file

@ -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?**

View file

@ -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.

View file

@ -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

View file

@ -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 <n>`, `!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 <n>` 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 <n>`, `!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

View file

@ -3,10 +3,12 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import logging import logging
import sys import os
import re import re
from urllib.parse import urlsplit, urlunsplit import sys
from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import aiohttp import aiohttp
@ -14,16 +16,18 @@ _api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_a
if str(_api_root) not in sys.path: if str(_api_root) not in sys.path:
sys.path.insert(0, str(_api_root)) sys.path.insert(0, str(_api_root))
from lambda_agent_api.agent_api import AgentApi, AgentException from lambda_agent_api.agent_api import AgentApi, AgentBusyException, AgentException # noqa: E402
from lambda_agent_api.server import ( from lambda_agent_api.client import EClientMessage, MsgUserMessage # noqa: E402
MsgError, from lambda_agent_api.server import AgentEventUnion, MsgEventEnd, ServerMessage # noqa: E402
MsgEventEnd,
MsgEventTextChunk,
MsgGracefulDisconnect,
ServerMessage,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_DEBUG_STREAM = os.environ.get("SURFACES_AGENT_DEBUG_STREAM", "").strip().lower() in {
"1",
"true",
"yes",
}
_POST_END_DRAIN_MS = int(os.environ.get("SURFACES_AGENT_POST_END_DRAIN_MS", "120"))
_STREAM_IDLE_TIMEOUT_MS = int(os.environ.get("SURFACES_AGENT_IDLE_TIMEOUT_MS", "60000"))
class AgentApiWrapper(AgentApi): class AgentApiWrapper(AgentApi):
@ -78,7 +82,7 @@ class AgentApiWrapper(AgentApi):
def _build_ws_url(base_url: str, chat_id: int | str) -> str: def _build_ws_url(base_url: str, chat_id: int | str) -> str:
return base_url.rstrip("/") + f"/agent_ws/?thread_id={chat_id}" return base_url.rstrip("/") + f"/agent_ws/?thread_id={chat_id}"
def for_chat(self, chat_id: int | str) -> "AgentApiWrapper": def for_chat(self, chat_id: int | str) -> AgentApiWrapper:
return type(self)( return type(self)(
agent_id=self.id, agent_id=self.id,
base_url=self._base_url, base_url=self._base_url,
@ -133,7 +137,7 @@ class AgentApiWrapper(AgentApi):
if self.callback: if self.callback:
self.callback(event) self.callback(event)
if self._current_queue and hasattr(event, "code") and hasattr(event, "details"): if self._current_queue and hasattr(event, "code") and hasattr(event, "details"):
await self._current_queue.put(AgentException(getattr(event, "code"), getattr(event, "details"))) await self._current_queue.put(AgentException(event.code, event.details))
async def _listen(self): async def _listen(self):
try: try:
@ -143,6 +147,13 @@ class AgentApiWrapper(AgentApi):
outgoing_msg = ServerMessage.validate_json(msg.data) outgoing_msg = ServerMessage.validate_json(msg.data)
if self._is_text_event(outgoing_msg): if self._is_text_event(outgoing_msg):
if _DEBUG_STREAM:
logger.warning(
"[%s] text chunk queue=%s text=%r",
self.id,
self._current_queue is not None,
getattr(outgoing_msg, "text", "")[:80],
)
if self._current_queue: if self._current_queue:
await self._current_queue.put(outgoing_msg) await self._current_queue.put(outgoing_msg)
elif self.callback: elif self.callback:
@ -152,6 +163,13 @@ class AgentApiWrapper(AgentApi):
elif self._is_end_event(outgoing_msg): elif self._is_end_event(outgoing_msg):
self.last_tokens_used = outgoing_msg.tokens_used self.last_tokens_used = outgoing_msg.tokens_used
if _DEBUG_STREAM:
logger.warning(
"[%s] end event queue=%s tokens=%s",
self.id,
self._current_queue is not None,
getattr(outgoing_msg, "tokens_used", None),
)
await self._publish_event(outgoing_msg) await self._publish_event(outgoing_msg)
elif self._is_kind(outgoing_msg, "ERROR"): elif self._is_kind(outgoing_msg, "ERROR"):
@ -184,3 +202,114 @@ class AgentApiWrapper(AgentApi):
logger.error("[%s] Error in listen loop: %s", self.id, exc) logger.error("[%s] Error in listen loop: %s", self.id, exc)
finally: finally:
await self._cleanup() await self._cleanup()
async def send_message(
self, text: str, attachments: list[str] | None = None
) -> AsyncIterator[AgentEventUnion]:
if not self._connected or not self._ws:
raise AgentException(
code="NOT_CONNECTED", details="Not connected. Call connect() first."
)
if self._request_lock.locked():
raise AgentBusyException("Agent is currently processing another request")
await self._request_lock.acquire()
try:
self._current_queue = asyncio.Queue()
message = MsgUserMessage(
type=EClientMessage.USER_MESSAGE,
text=text,
attachments=attachments or [],
)
await self._ws.send_str(message.model_dump_json())
logger.debug("[%s] Sent message: %s...", self.id, text[:50])
while True:
try:
chunk = await asyncio.wait_for(
self._current_queue.get(),
timeout=max(_STREAM_IDLE_TIMEOUT_MS, 0) / 1000,
)
except TimeoutError as exc:
raise AgentException(
"TIMEOUT",
(
"Timed out waiting for the next agent stream event "
f"after {max(_STREAM_IDLE_TIMEOUT_MS, 0)}ms"
),
) from exc
if isinstance(chunk, Exception):
raise chunk
if isinstance(chunk, MsgEventEnd):
self.last_tokens_used = chunk.tokens_used
async for late_chunk in self._drain_post_end_events():
yield late_chunk
break
yield chunk
finally:
if self._current_queue:
orphan_queue = self._current_queue
self._current_queue = None
while not orphan_queue.empty():
try:
orphan_msg = orphan_queue.get_nowait()
if isinstance(orphan_msg, Exception):
logger.debug(
"[%s] Dropped exception from queue during cleanup: %s",
self.id,
orphan_msg,
)
continue
if self.callback:
self.callback(orphan_msg)
else:
logger.debug("[%s] Dropped orphaned message during cleanup", self.id)
except asyncio.QueueEmpty:
break
if self._request_lock.locked():
self._request_lock.release()
async def _drain_post_end_events(self) -> AsyncIterator[AgentEventUnion]:
if self._current_queue is None:
return
timeout_s = max(_POST_END_DRAIN_MS, 0) / 1000
while True:
try:
chunk = await asyncio.wait_for(self._current_queue.get(), timeout=timeout_s)
except TimeoutError:
break
if isinstance(chunk, Exception):
logger.warning("[%s] dropping post-END exception: %s", self.id, chunk)
continue
if isinstance(chunk, MsgEventEnd):
self.last_tokens_used = chunk.tokens_used
if _DEBUG_STREAM:
logger.warning(
"[%s] dropped duplicate END tokens=%s",
self.id,
chunk.tokens_used,
)
continue
if _DEBUG_STREAM and self._is_text_event(chunk):
logger.warning(
"[%s] recovered post-END text chunk=%r",
self.id,
getattr(chunk, "text", "")[:80],
)
yield chunk

View file

@ -1,8 +1,9 @@
# platform/interface.py # platform/interface.py
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import datetime from datetime import datetime
from typing import Any, AsyncIterator, Literal, Protocol from typing import Any, Literal, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -34,6 +35,7 @@ class MessageResponse(BaseModel):
class MessageChunk(BaseModel): class MessageChunk(BaseModel):
"""Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True.""" """Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True."""
message_id: str message_id: str
delta: str delta: str
finished: bool finished: bool
@ -50,6 +52,7 @@ class UserSettings(BaseModel):
class AgentEvent(BaseModel): class AgentEvent(BaseModel):
"""Webhook-уведомление от платформы — агент закончил долгую задачу.""" """Webhook-уведомление от платформы — агент закончил долгую задачу."""
event_id: str event_id: str
user_id: str user_id: str
chat_id: str chat_id: str
@ -96,4 +99,5 @@ class PlatformClient(Protocol):
class WebhookReceiver(Protocol): class WebhookReceiver(Protocol):
"""Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу.""" """Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу."""
async def on_agent_event(self, event: AgentEvent) -> None: ... async def on_agent_event(self, event: AgentEvent) -> None: ...

View file

@ -4,8 +4,9 @@ from __future__ import annotations
import asyncio import asyncio
import random import random
import uuid import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any, AsyncIterator, Literal from typing import Any, Literal
import structlog import structlog
@ -222,14 +223,16 @@ class MockPlatformClient:
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}" response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
tokens = len(text.split()) * 2 tokens = len(text.split()) * 2
self._messages[key].append({ self._messages[key].append(
{
"message_id": message_id, "message_id": message_id,
"user_text": text, "user_text": text,
"response": response, "response": response,
"tokens_used": tokens, "tokens_used": tokens,
"finished": True, "finished": True,
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
}) }
)
return message_id, response, tokens return message_id, response, tokens
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None: async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:

View file

@ -2,11 +2,19 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from typing import AsyncIterator
from sdk.agent_api_wrapper import AgentApiWrapper from sdk.agent_api_wrapper import AgentApiWrapper
from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings from sdk.interface import (
Attachment,
MessageChunk,
MessageResponse,
PlatformClient,
PlatformError,
User,
UserSettings,
)
from sdk.prototype_state import PrototypeStateStore from sdk.prototype_state import PrototypeStateStore
@ -83,7 +91,10 @@ class RealPlatformClient(PlatformClient):
if hasattr(chat_api, "last_tokens_used"): if hasattr(chat_api, "last_tokens_used"):
chat_api.last_tokens_used = 0 chat_api.last_tokens_used = 0
async for event in self._stream_agent_events(chat_api, text, attachments=attachments): try:
async for event in self._stream_agent_events(
chat_api, text, attachments=attachments
):
message_id = user_id message_id = user_id
if self._is_text_event(event): if self._is_text_event(event):
chunk_text = getattr(event, "text", "") chunk_text = getattr(event, "text", "")
@ -96,6 +107,8 @@ class RealPlatformClient(PlatformClient):
attachment = self._attachment_from_send_file_event(event) attachment = self._attachment_from_send_file_event(event)
if attachment is not None: if attachment is not None:
sent_attachments.append(attachment) sent_attachments.append(attachment)
except Exception as exc:
await self._handle_chat_api_failure(chat_id, exc)
if not saw_end_event: if not saw_end_event:
tokens_used = getattr(chat_api, "last_tokens_used", tokens_used) tokens_used = getattr(chat_api, "last_tokens_used", tokens_used)
@ -124,7 +137,10 @@ class RealPlatformClient(PlatformClient):
if hasattr(chat_api, "last_tokens_used"): if hasattr(chat_api, "last_tokens_used"):
chat_api.last_tokens_used = 0 chat_api.last_tokens_used = 0
saw_end_event = False saw_end_event = False
async for event in self._stream_agent_events(chat_api, text, attachments=attachments): try:
async for event in self._stream_agent_events(
chat_api, text, attachments=attachments
):
if self._is_text_event(event): if self._is_text_event(event):
yield MessageChunk( yield MessageChunk(
message_id=user_id, message_id=user_id,
@ -145,6 +161,8 @@ class RealPlatformClient(PlatformClient):
continue continue
else: else:
continue continue
except Exception as exc:
await self._handle_chat_api_failure(chat_id, exc)
if not saw_end_event: if not saw_end_event:
tokens_used = getattr(chat_api, "last_tokens_used", 0) tokens_used = getattr(chat_api, "last_tokens_used", 0)
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used) await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
@ -197,6 +215,11 @@ class RealPlatformClient(PlatformClient):
async for event in event_stream: async for event in event_stream:
yield event yield event
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 @staticmethod
def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
if not attachments: if not attachments:
@ -265,7 +288,7 @@ class RealPlatformClient(PlatformClient):
size = getattr(event, "size", None) size = getattr(event, "size", None)
workspace_path = location workspace_path = location
if workspace_path.startswith("/workspace/"): if workspace_path.startswith("/workspace/"):
workspace_path = workspace_path[len("/workspace/"):] workspace_path = workspace_path[len("/workspace/") :]
elif workspace_path == "/workspace": elif workspace_path == "/workspace":
workspace_path = "" workspace_path = ""
return Attachment( return Attachment(

View file

@ -6,7 +6,11 @@ from unittest.mock import AsyncMock
from nio.api import RoomVisibility from nio.api import RoomVisibility
from nio.responses import RoomCreateError from nio.responses import RoomCreateError
from adapter.matrix.handlers.chat import make_handle_archive, make_handle_new_chat, make_handle_rename 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 adapter.matrix.store import get_room_meta, set_user_meta
from core.auth import AuthManager from core.auth import AuthManager
from core.chat import ChatManager from core.chat import ChatManager
@ -28,7 +32,9 @@ async def _setup():
async def test_mat04_new_chat_calls_room_put_state_with_space_id(): async def test_mat04_new_chat_calls_room_put_state_with_space_id():
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() 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( client = SimpleNamespace(
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")), room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")),
@ -59,7 +65,7 @@ async def test_mat04_new_chat_calls_room_put_state_with_space_id():
assert kwargs.get("state_key") == "!newroom:ex" assert kwargs.get("state_key") == "!newroom:ex"
room_meta = await get_room_meta(store, "!newroom:ex") room_meta = await get_room_meta(store, "!newroom:ex")
assert room_meta is not None assert room_meta is not None
assert room_meta["platform_chat_id"] == "matrix:!newroom:ex" assert room_meta["platform_chat_id"] == "1"
assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result) assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result)
@ -169,10 +175,14 @@ async def test_mat11b_rename_from_unregistered_room_returns_error_message():
async def test_mat12_room_create_error_returns_user_message(): async def test_mat12_room_create_error_returns_user_message():
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() 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( 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_put_state=AsyncMock(),
room_invite=AsyncMock(), room_invite=AsyncMock(),
) )

View file

@ -1,8 +1,7 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock
import pytest import pytest
@ -15,7 +14,6 @@ from adapter.matrix.handlers.context_commands import (
) )
from adapter.matrix.store import ( from adapter.matrix.store import (
get_load_pending, get_load_pending,
set_load_pending, set_load_pending,
set_room_meta, set_room_meta,
) )
@ -48,7 +46,7 @@ async def test_save_command_auto_name_records_session():
await set_room_meta( await set_room_meta(
store, store,
"!room:example.org", "!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "matrix:room-1"}, {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
) )
handler = make_handle_save( handler = make_handle_save(
agent_api=platform._agent_api, agent_api=platform._agent_api,
@ -71,7 +69,7 @@ async def test_save_command_auto_name_records_session():
sessions = await platform._prototype_state.list_saved_sessions("u1") sessions = await platform._prototype_state.list_saved_sessions("u1")
assert len(sessions) == 1 assert len(sessions) == 1
assert sessions[0]["name"].startswith("context-") assert sessions[0]["name"].startswith("context-")
assert sessions[0]["source_context_id"] == "matrix:room-1" assert sessions[0]["source_context_id"] == "41"
@pytest.mark.asyncio @pytest.mark.asyncio
@ -81,7 +79,7 @@ async def test_save_command_with_name_uses_given_name():
await set_room_meta( await set_room_meta(
store, store,
"!room:example.org", "!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "matrix:room-1"}, {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
) )
handler = make_handle_save( handler = make_handle_save(
agent_api=platform._agent_api, agent_api=platform._agent_api,
@ -119,7 +117,13 @@ async def test_load_command_shows_numbered_list_and_sets_pending():
handler = make_handle_load(store=runtime.store, prototype_state=platform._prototype_state) 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=[]) 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) result = await handler(
event,
runtime.auth_mgr,
platform,
runtime.chat_mgr,
runtime.settings_mgr,
)
assert "1. session-a" in result[0].text assert "1. session-a" in result[0].text
assert "2. session-b" in result[0].text assert "2. session-b" in result[0].text
@ -150,16 +154,28 @@ async def test_reset_command_assigns_new_platform_chat_id():
runtime = build_runtime(platform=platform) runtime = build_runtime(platform=platform)
store = runtime.store store = runtime.store
await set_room_meta(store, "!room:example.org", {"platform_chat_id": "matrix:!room:example.org"}) await set_room_meta(store, "!room:example.org", {"platform_chat_id": "7"})
handler = make_handle_reset(store=store, prototype_state=prototype_state) 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=[]) 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) 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") new_id = await get_platform_chat_id(store, "!room:example.org")
assert new_id != "matrix:!room:example.org" assert new_id != "7"
assert new_id.startswith("matrix:!room:example.org#") assert new_id == "1"
assert "сброшен" in result[0].text.lower() assert "сброшен" in result[0].text.lower()
@ -177,17 +193,29 @@ async def test_context_command_shows_current_snapshot():
await set_room_meta( await set_room_meta(
runtime.store, runtime.store,
"!room:example.org", "!room:example.org",
{"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "matrix:room-1"}, {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
) )
await platform._prototype_state.set_current_session("matrix:room-1", "session-a") await platform._prototype_state.set_current_session("41", "session-a")
await platform._prototype_state.set_last_tokens_used("matrix:room-1", 99) await platform._prototype_state.set_last_tokens_used("41", 99)
await platform._prototype_state.add_saved_session("u1", "session-a") await platform._prototype_state.add_saved_session("u1", "session-a")
handler = make_handle_context(store=runtime.store, prototype_state=platform._prototype_state) 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=[]) 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) result = await handler(
event,
runtime.auth_mgr,
platform,
runtime.chat_mgr,
runtime.settings_mgr,
)
assert "Контекст чата: matrix:room-1" in result[0].text assert "Контекст чата: 41" in result[0].text
assert "Сессия: session-a" in result[0].text assert "Сессия: session-a" in result[0].text
assert "Токены (последний ответ): 99" in result[0].text assert "Токены (последний ответ): 99" in result[0].text
assert "session-a" in result[0].text assert "session-a" in result[0].text
@ -203,7 +231,7 @@ async def test_bot_intercepts_numeric_load_selection():
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:room-1", "platform_chat_id": "41",
}, },
) )
client = SimpleNamespace( client = SimpleNamespace(
@ -223,7 +251,7 @@ async def test_bot_intercepts_numeric_load_selection():
await bot.on_room_message(room, event) await bot.on_room_message(room, event)
platform.send_message.assert_awaited_once() platform.send_message.assert_awaited_once()
assert await platform._prototype_state.get_current_session("matrix:room-1") == "session-a" assert await platform._prototype_state.get_current_session("41") == "session-a"
assert await platform._prototype_state.get_current_session("C1") == "session-a" assert await platform._prototype_state.get_current_session("C1") == "session-a"
client.room_send.assert_awaited_once_with( client.room_send.assert_awaited_once_with(
"!room:example.org", "!room:example.org",

View file

@ -272,10 +272,7 @@ async def test_bot_assigns_platform_chat_id_for_existing_managed_room():
await bot.on_room_message(room, event) await bot.on_room_message(room, event)
assert ( assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
await get_platform_chat_id(runtime.store, "!chat1:example.org")
== "matrix:!chat1:example.org"
)
runtime.dispatcher.dispatch.assert_awaited_once() runtime.dispatcher.dispatch.assert_awaited_once()
@ -287,7 +284,7 @@ async def test_bot_routes_plain_messages_via_platform_chat_id():
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:ctx-1", "platform_chat_id": "41",
}, },
) )
client = SimpleNamespace(user_id="@bot:example.org") client = SimpleNamespace(user_id="@bot:example.org")
@ -300,7 +297,7 @@ async def test_bot_routes_plain_messages_via_platform_chat_id():
await bot.on_room_message(room, event) await bot.on_room_message(room, event)
dispatched = runtime.dispatcher.dispatch.await_args.args[0] dispatched = runtime.dispatcher.dispatch.await_args.args[0]
assert dispatched.chat_id == "matrix:ctx-1" assert dispatched.chat_id == "41"
assert dispatched.text == "hello" assert dispatched.text == "hello"
@ -313,7 +310,7 @@ async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, m
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:ctx-1", "platform_chat_id": "41",
}, },
) )
client = SimpleNamespace( client = SimpleNamespace(
@ -539,7 +536,7 @@ async def test_next_normal_message_commits_staged_attachments():
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:ctx-1", "platform_chat_id": "41",
}, },
) )
await add_staged_attachment( await add_staged_attachment(
@ -584,7 +581,7 @@ async def test_failed_commit_preserves_staged_attachments():
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:ctx-1", "platform_chat_id": "41",
}, },
) )
await add_staged_attachment( await add_staged_attachment(
@ -622,7 +619,7 @@ async def test_bot_keeps_commands_on_local_chat_id():
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:ctx-1", "platform_chat_id": "41",
}, },
) )
client = SimpleNamespace(user_id="@bot:example.org") client = SimpleNamespace(user_id="@bot:example.org")
@ -647,7 +644,7 @@ async def test_bot_leaves_existing_platform_chat_id_unchanged():
{ {
"chat_id": "C1", "chat_id": "C1",
"matrix_user_id": "@alice:example.org", "matrix_user_id": "@alice:example.org",
"platform_chat_id": "matrix:existing", "platform_chat_id": "99",
}, },
) )
client = SimpleNamespace(user_id="@bot:example.org") client = SimpleNamespace(user_id="@bot:example.org")
@ -659,7 +656,7 @@ async def test_bot_leaves_existing_platform_chat_id_unchanged():
await bot.on_room_message(room, event) await bot.on_room_message(room, event)
assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "matrix:existing" assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "99"
runtime.dispatcher.dispatch.assert_awaited_once() runtime.dispatcher.dispatch.assert_awaited_once()
@ -686,10 +683,7 @@ async def test_bot_assigns_platform_chat_id_before_load_selection():
await bot.on_room_message(room, event) await bot.on_room_message(room, event)
assert ( assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
await get_platform_chat_id(runtime.store, "!chat1:example.org")
== "matrix:!chat1:example.org"
)
client.room_send.assert_awaited_once_with( client.room_send.assert_awaited_once_with(
"!chat1:example.org", "!chat1:example.org",
"m.room.message", "m.room.message",

View file

@ -64,7 +64,7 @@ async def test_mat01_invite_creates_space_and_chat1():
assert room_meta is not None assert room_meta is not None
assert room_meta["chat_id"] == "C4" assert room_meta["chat_id"] == "C4"
assert room_meta["space_id"] == "!space:example.org" assert room_meta["space_id"] == "!space:example.org"
assert room_meta["platform_chat_id"] == "matrix:!chat1:example.org" assert room_meta["platform_chat_id"] == "1"
assert user_meta["next_chat_index"] == 5 assert user_meta["next_chat_index"] == 5
chats = await runtime.chat_mgr.list_active("@alice:example.org") chats = await runtime.chat_mgr.list_active("@alice:example.org")
@ -120,7 +120,7 @@ async def test_mat03_no_hardcoded_c1():
room_meta = await get_room_meta(runtime.store, "!chat1:example.org") room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
assert room_meta is not None assert room_meta is not None
assert room_meta["chat_id"] == "C7" assert room_meta["chat_id"] == "C7"
assert room_meta["platform_chat_id"] == "matrix:!chat1:example.org" assert room_meta["platform_chat_id"] == "1"
user_meta = await get_user_meta(runtime.store, "@alice:example.org") user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None assert user_meta is not None

View file

@ -15,6 +15,7 @@ from adapter.matrix.store import (
get_staged_attachments, get_staged_attachments,
get_user_meta, get_user_meta,
next_chat_id, next_chat_id,
next_platform_chat_id,
remove_staged_attachment_at, remove_staged_attachment_at,
set_pending_confirm, set_pending_confirm,
set_platform_chat_id, set_platform_chat_id,
@ -107,6 +108,12 @@ async def test_next_chat_id_increments(store: InMemoryStore):
assert await next_chat_id(store, uid) == "C3" 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): async def test_skills_message_roundtrip(store: InMemoryStore):
await set_skills_message_id(store, "!room", "$event") await set_skills_message_id(store, "!room", "$event")
assert await get_skills_message_id(store, "!room") == "$event" assert await get_skills_message_id(store, "!room") == "$event"
@ -151,7 +158,8 @@ async def test_staged_attachments_roundtrip(store: InMemoryStore):
], ],
) )
async def test_staged_attachments_invalid_container_state_returns_empty_list( async def test_staged_attachments_invalid_container_state_returns_empty_list(
store: InMemoryStore, stored_value, store: InMemoryStore,
stored_value,
): ):
room_id = "!room:m.org" room_id = "!room:m.org"
user_id = "@alice:m.org" user_id = "@alice:m.org"

View file

@ -1,11 +1,12 @@
import asyncio import asyncio
import pytest import pytest
from lambda_agent_api.server import MsgEventEnd, MsgEventTextChunk
from core.protocol import SettingsAction
import sdk.agent_api_wrapper as agent_api_wrapper_module import sdk.agent_api_wrapper as agent_api_wrapper_module
from core.protocol import SettingsAction
from sdk.agent_api_wrapper import AgentApiWrapper from sdk.agent_api_wrapper import AgentApiWrapper
from sdk.interface import Attachment, MessageChunk, MessageResponse, UserSettings from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformError, UserSettings
from sdk.prototype_state import PrototypeStateStore from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient from sdk.real import RealPlatformClient
@ -110,6 +111,23 @@ class AttachmentTrackingChatAgentApi:
self.last_tokens_used = 5 self.last_tokens_used = 5
class FlakyChatAgentApi:
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 ConnectionError("Connection closed")
yield
class SendFileEvent: class SendFileEvent:
def __init__(self, *, workspace_path: str, mime_type: str, filename: str, size: int) -> None: def __init__(self, *, workspace_path: str, mime_type: str, filename: str, size: int) -> None:
self.type = "AGENT_EVENT_SEND_FILE" self.type = "AGENT_EVENT_SEND_FILE"
@ -180,6 +198,26 @@ class FakeWebSocket:
return self._messages.pop(0) return self._messages.pop(0)
class QueueFeedingWebSocket:
def __init__(self, owner, queued_events: list[object]) -> None:
self.owner = owner
self.queued_events = list(queued_events)
self.sent_payloads: list[str] = []
async def send_str(self, payload: str) -> None:
self.sent_payloads.append(payload)
for event in self.queued_events:
await self.owner._current_queue.put(event)
class SilentWebSocket:
def __init__(self) -> None:
self.sent_payloads: list[str] = []
async def send_str(self, payload: str) -> None:
self.sent_payloads.append(payload)
class MessageResponseWithAttachments(MessageResponse): class MessageResponseWithAttachments(MessageResponse):
attachments: list[Attachment] = [] attachments: list[Attachment] = []
@ -271,6 +309,68 @@ def test_agent_api_wrapper_falls_back_to_legacy_url_constructor(monkeypatch):
assert wrapper.last_tokens_used == 0 assert wrapper.last_tokens_used == 0
@pytest.mark.asyncio
async def test_agent_api_wrapper_recovers_late_text_after_first_end(monkeypatch):
def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
self.id = agent_id
self.url = base_url
self.callback = kwargs.get("callback")
self.on_disconnect = kwargs.get("on_disconnect")
monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
wrapper = AgentApiWrapper(
agent_id="agent-1",
base_url="https://agent.example.com/v1/agent_ws",
chat_id="chat-1",
)
wrapper._connected = True
wrapper._request_lock = asyncio.Lock()
wrapper._current_queue = None
wrapper._ws = QueueFeedingWebSocket(
wrapper,
[
MsgEventTextChunk(text="Иллюстра"),
MsgEventEnd(tokens_used=5),
MsgEventTextChunk(text="ция"),
MsgEventEnd(tokens_used=5),
],
)
chunks = []
async for chunk in wrapper.send_message("hello"):
chunks.append(chunk)
assert [chunk.text for chunk in chunks] == ["Иллюстра", "ция"]
assert wrapper.last_tokens_used == 5
@pytest.mark.asyncio
async def test_agent_api_wrapper_times_out_on_idle_stream(monkeypatch):
def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
self.id = agent_id
self.url = base_url
self.callback = kwargs.get("callback")
self.on_disconnect = kwargs.get("on_disconnect")
monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
monkeypatch.setattr(agent_api_wrapper_module, "_STREAM_IDLE_TIMEOUT_MS", 10)
wrapper = AgentApiWrapper(
agent_id="agent-1",
base_url="https://agent.example.com/v1/agent_ws",
chat_id="chat-1",
)
wrapper._connected = True
wrapper._request_lock = asyncio.Lock()
wrapper._current_queue = None
wrapper._ws = SilentWebSocket()
with pytest.raises(agent_api_wrapper_module.AgentException, match="Timed out waiting"):
async for _ in wrapper.send_message("hello"):
pass
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_real_platform_client_get_or_create_user_uses_local_state(): async def test_real_platform_client_get_or_create_user_uses_local_state():
client = RealPlatformClient( client = RealPlatformClient(
@ -418,6 +518,58 @@ async def test_real_platform_client_reuses_cached_chat_client():
assert agent_api.created_chat_ids == ["chat-1"] assert agent_api.created_chat_ids == ["chat-1"]
assert agent_api.instances["chat-1"].calls == ["hello", "again"] assert agent_api.instances["chat-1"].calls == ["hello", "again"]
assert agent_api.instances["chat-1"].connect_calls == 1 assert agent_api.instances["chat-1"].connect_calls == 1
assert agent_api.instances["chat-1"].close_calls == 0
@pytest.mark.asyncio
async def test_real_platform_client_wraps_connection_closed_as_platform_error():
agent_api = FakeAgentApiFactory()
agent_api.instances["chat-1"] = FlakyChatAgentApi("chat-1")
agent_api.for_chat = lambda chat_id: agent_api.instances.setdefault(
chat_id, FlakyChatAgentApi(chat_id)
)
client = RealPlatformClient(
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
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 "chat-1" not in client._chat_apis
assert agent_api.instances["chat-1"].close_calls == 1
@pytest.mark.asyncio
async def test_real_platform_client_reconnects_after_closed_chat_api():
agent_api = FakeAgentApiFactory()
flaky = FlakyChatAgentApi("chat-1")
healthy = AttachmentTrackingChatAgentApi("chat-1")
provided = iter([flaky, healthy])
def for_chat(chat_id: str):
chat_api = next(provided)
agent_api.created_chat_ids.append(chat_id)
agent_api.instances[chat_id] = chat_api
return chat_api
agent_api.for_chat = for_chat
client = RealPlatformClient(
agent_api=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
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_chat_ids == ["chat-1", "chat-1"]
assert healthy.calls == [("again", None)]
@pytest.mark.asyncio @pytest.mark.asyncio
@ -462,7 +614,9 @@ async def test_real_platform_client_creates_distinct_clients_per_chat():
async def test_real_platform_client_serializes_same_chat_streams_across_send_paths(): async def test_real_platform_client_serializes_same_chat_streams_across_send_paths():
agent_api = FakeAgentApiFactory() agent_api = FakeAgentApiFactory()
agent_api.instances["chat-1"] = BlockingChatAgentApi("chat-1") agent_api.instances["chat-1"] = BlockingChatAgentApi("chat-1")
agent_api.for_chat = lambda chat_id: agent_api.instances.setdefault(chat_id, BlockingChatAgentApi(chat_id)) agent_api.for_chat = lambda chat_id: agent_api.instances.setdefault(
chat_id, BlockingChatAgentApi(chat_id)
)
client = RealPlatformClient( client = RealPlatformClient(
agent_api=agent_api, agent_api=agent_api,
prototype_state=PrototypeStateStore(), prototype_state=PrototypeStateStore(),
@ -587,10 +741,12 @@ async def test_agent_api_wrapper_transparently_surfaces_modern_events(monkeypatc
monkeypatch.setattr( monkeypatch.setattr(
agent_api_wrapper_module.AgentApi, agent_api_wrapper_module.AgentApi,
"__init__", "__init__",
lambda self, agent_id, base_url=None, chat_id=0, **kwargs: setattr(self, "id", agent_id) lambda self, agent_id, base_url=None, chat_id=0, **kwargs: (
setattr(self, "id", agent_id)
or setattr(self, "callback", kwargs.get("callback")) or setattr(self, "callback", kwargs.get("callback"))
or setattr(self, "on_disconnect", kwargs.get("on_disconnect")) or setattr(self, "on_disconnect", kwargs.get("on_disconnect"))
or setattr(self, "_current_queue", None), or setattr(self, "_current_queue", None)
),
) )
wrapper = AgentApiWrapper( wrapper = AgentApiWrapper(