feat: finalize matrix platform audit and docs
This commit is contained in:
parent
6422c7db58
commit
4524a6abc8
30 changed files with 3093 additions and 176 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ build/
|
||||||
|
|
||||||
# Git worktrees (не трекаем в репо)
|
# Git worktrees (не трекаем в репо)
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
external/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Thread: Matrix dev prototype — состояние агента и платформы
|
# Thread: Matrix dev prototype — состояние агента и платформы
|
||||||
|
|
||||||
## Status: OPEN
|
## Status: IN PROGRESS
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|
|
||||||
81
.planning/threads/matrix-file-ingestion-context.md
Normal file
81
.planning/threads/matrix-file-ingestion-context.md
Normal 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 всё ещё не подтверждён.
|
||||||
22
README.md
22
README.md
|
|
@ -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` | Текущая сессия и список сохранений |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
245
docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
Normal file
245
docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
Normal 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-соединения
|
||||||
|
|
@ -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?**
|
||||||
480
docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
Normal file
480
docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
555
docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
Normal file
555
docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: ...
|
||||||
|
|
|
||||||
21
sdk/mock.py
21
sdk/mock.py
|
|
@ -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,
|
{
|
||||||
"user_text": text,
|
"message_id": message_id,
|
||||||
"response": response,
|
"user_text": text,
|
||||||
"tokens_used": tokens,
|
"response": response,
|
||||||
"finished": True,
|
"tokens_used": tokens,
|
||||||
"created_at": datetime.now(UTC).isoformat(),
|
"finished": True,
|
||||||
})
|
"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:
|
||||||
|
|
|
||||||
97
sdk/real.py
97
sdk/real.py
|
|
@ -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,19 +91,24 @@ 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:
|
||||||
message_id = user_id
|
async for event in self._stream_agent_events(
|
||||||
if self._is_text_event(event):
|
chat_api, text, attachments=attachments
|
||||||
chunk_text = getattr(event, "text", "")
|
):
|
||||||
if chunk_text:
|
message_id = user_id
|
||||||
response_parts.append(chunk_text)
|
if self._is_text_event(event):
|
||||||
elif self._is_end_event(event):
|
chunk_text = getattr(event, "text", "")
|
||||||
tokens_used = getattr(event, "tokens_used", tokens_used)
|
if chunk_text:
|
||||||
saw_end_event = True
|
response_parts.append(chunk_text)
|
||||||
elif self._is_send_file_event(event):
|
elif self._is_end_event(event):
|
||||||
attachment = self._attachment_from_send_file_event(event)
|
tokens_used = getattr(event, "tokens_used", tokens_used)
|
||||||
if attachment is not None:
|
saw_end_event = True
|
||||||
sent_attachments.append(attachment)
|
elif self._is_send_file_event(event):
|
||||||
|
attachment = self._attachment_from_send_file_event(event)
|
||||||
|
if attachment is not None:
|
||||||
|
sent_attachments.append(attachment)
|
||||||
|
except Exception as exc:
|
||||||
|
await self._handle_chat_api_failure(chat_id, exc)
|
||||||
|
|
||||||
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,27 +137,32 @@ 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:
|
||||||
if self._is_text_event(event):
|
async for event in self._stream_agent_events(
|
||||||
yield MessageChunk(
|
chat_api, text, attachments=attachments
|
||||||
message_id=user_id,
|
):
|
||||||
delta=getattr(event, "text", ""),
|
if self._is_text_event(event):
|
||||||
finished=False,
|
yield MessageChunk(
|
||||||
)
|
message_id=user_id,
|
||||||
elif self._is_end_event(event):
|
delta=getattr(event, "text", ""),
|
||||||
tokens_used = getattr(event, "tokens_used", 0)
|
finished=False,
|
||||||
saw_end_event = True
|
)
|
||||||
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
|
elif self._is_end_event(event):
|
||||||
yield MessageChunk(
|
tokens_used = getattr(event, "tokens_used", 0)
|
||||||
message_id=user_id,
|
saw_end_event = True
|
||||||
delta="",
|
await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
|
||||||
finished=True,
|
yield MessageChunk(
|
||||||
tokens_used=tokens_used,
|
message_id=user_id,
|
||||||
)
|
delta="",
|
||||||
elif self._is_send_file_event(event):
|
finished=True,
|
||||||
continue
|
tokens_used=tokens_used,
|
||||||
else:
|
)
|
||||||
continue
|
elif self._is_send_file_event(event):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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: (
|
||||||
or setattr(self, "callback", kwargs.get("callback"))
|
setattr(self, "id", agent_id)
|
||||||
or setattr(self, "on_disconnect", kwargs.get("on_disconnect"))
|
or setattr(self, "callback", kwargs.get("callback"))
|
||||||
or setattr(self, "_current_queue", None),
|
or setattr(self, "on_disconnect", kwargs.get("on_disconnect"))
|
||||||
|
or setattr(self, "_current_queue", None)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = AgentApiWrapper(
|
wrapper = AgentApiWrapper(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue