# Phase 4: Matrix MVP — Shared Agent Context + Context Management — Research

**Researched:** 2026-04-16
**Domain:** Matrix bot, AgentApi WebSocket client, context management commands, Docker packaging
**Confidence:** HIGH (all findings verified against actual source files in this repo)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Архитектура платформы:**
- Один контейнер = один чат: `AgentService` с `thread_id = "default"` — намеренная архитектура. Не менять.
- Убрать thread_id патч: наш коммит `1dca2c1` в `external/platform-agent` удаляем. Переходим на `origin/main` platform-agent.
- Удалить `build_thread_key`: функция больше не нужна. Убрать из `sdk/agent_session.py` и `sdk/real.py`.
- Заменить `AgentSessionClient` на `AgentApi`: использовать `AgentApi` из `external/platform-agent_api/lambda_agent_api/agent_api.py`.

**!save:** Синтаксис `!save` (автоимя) или `!save [имя]`. Механизм: посылаем агенту текстовое сообщение. Имена сохранений хранятся в `PrototypeStateStore`.

**!load:** `!load` без аргументов → нумерованный список. Пользователь вводит число. Выход: `0` или `!cancel`. После выбора — посылаем агенту текстовое сообщение. Состояние ожидания в Matrix store.

**!reset:** Confirmation-диалог с `!yes`/`!save имя`/`!no`. `!yes` → `POST {AGENT_BASE_URL}/reset`. Fallback если 404: сообщение пользователю.

**!context:** Показывает имя сессии, токены, список сохранений. Без вызовов агента.

**Dockerfile + docker-compose:** Для Matrix-бота. Env через `.env`. Platform-agent — отдельно.

### Claude's Discretion

- Структура хранения saved sessions в PrototypeStateStore (dict name→timestamp)
- Формат автоимени для !save без аргументов
- HTTP клиент для POST /reset (aiohttp или httpx)
- Точный формат промптов к агенту для save/load

### Deferred Ideas (OUT OF SCOPE)

- Замена `PrototypeStateStore` на реальный control-plane из platform-master
- Skills интеграция через SkillsMiddleware
- E2EE для Matrix
- `!reset` через docker restart
- Суммаризация контекста
</user_constraints>

---

## Summary

Phase 4 replaces the custom `AgentSessionClient` with the production `AgentApi` from `lambda_agent_api`, adds four context management commands to the Matrix bot, and packages it in Docker. All findings are verified directly against source files.

**Primary recommendation:** Wire `AgentApi` as a persistent connection in `MatrixBot.__init__` (connect on start, close in finally block of `main()`). Expose it through `RealPlatformClient`. The four commands follow the existing handler registration pattern in `adapter/matrix/handlers/__init__.py`.

The `platform-agent` at `origin/main` already works with `AgentApi` — it does NOT require `thread_id` query param. Our local patch (`1dca2c1`) must be discarded and `external/platform-agent` reset to `origin/main`.

---

## Project Constraints (from CLAUDE.md)

- **Tech stack:** matrix-nio for Matrix — do not change without discussion
- **Platform client:** connected only via `sdk/interface.py` Protocol — core/ and adapters untouched when swapping implementation
- **No E2EE** — matrix-nio without python-olm
- **Hotfixes < 20 lines** → Claude Code directly; implementation → Codex via GSD
- **MATRIX_PLATFORM_BACKEND env var** controls mock vs real

---

## Standard Stack

### Core (verified)
| Library | Version | Purpose | Source |
|---------|---------|---------|--------|
| `lambda_agent_api` | local (external/platform-agent_api) | AgentApi WebSocket client | [VERIFIED: file read] |
| `aiohttp` | >=3.9 (surfaces-bot), >=3.13.4 (agent_api) | WebSocket transport inside AgentApi | [VERIFIED: pyproject.toml] |
| `pydantic` | >=2.5 | Message serialization (MsgUserMessage, MsgEventEnd, etc.) | [VERIFIED: server.py/client.py] |
| `httpx` | >=0.27 | HTTP client for POST /reset (already in deps) | [VERIFIED: pyproject.toml] |
| `structlog` | >=24.1 | Logging (existing pattern) | [VERIFIED: pyproject.toml] |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `aiohttp` | already a dep | Alternative HTTP for POST /reset | Could use instead of httpx — both available |

**Installation:** No new packages needed. `lambda_agent_api` is installed as a local path package (currently accessed via `sys.path` injection in tests; for production use, add to pyproject.toml as path dep or install via `pip install -e external/platform-agent_api`).

**Critical:** `lambda_agent_api` requires Python >=3.14 per its own `pyproject.toml`. The surfaces-bot requires Python >=3.11. [VERIFIED: pyproject.toml of both]. This is a **version mismatch** — see Pitfalls.

---

## Architecture Patterns

### AgentApi Constructor (verified)

```python
# Source: external/platform-agent_api/lambda_agent_api/agent_api.py
AgentApi(
    agent_id: str,               # arbitrary string ID, used in logs
    url: str,                    # WebSocket URL, e.g. "ws://127.0.0.1:8000/agent_ws/"
    callback: Optional[Callable[[ServerMessage], None]] = None,  # for orphaned msgs
    on_disconnect: Optional[Callable[['AgentApi'], None]] = None  # called on WS close
)
```

### AgentApi Lifecycle (verified)

```python
# Source: external/platform-agent_api/lambda_agent_api/agent_api.py
agent = AgentApi(agent_id="matrix-bot", url=ws_url)
await agent.connect()   # opens WS, waits for MsgStatus, starts _listen() task
# ... use agent ...
await agent.close()     # cancels _listen task, closes WS and session
```

`connect()` blocks until `MsgStatus` is received from server (5s timeout). After `connect()`, a background `_listen()` asyncio task runs continuously, routing server messages to an internal `asyncio.Queue`.

### AgentApi.send_message() semantics (verified)

```python
# Source: external/platform-agent_api/lambda_agent_api/agent_api.py, line 134
async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]:
```

- `AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd]` — **but** the generator `yield`s only `MsgEventTextChunk` chunks; it `break`s (stops) on `MsgEventEnd` without yielding it.
- `MsgEventEnd` carries `tokens_used: int` — to capture this, the caller must intercept the queue or handle `MsgEventEnd` in the `_listen` loop. **Currently `send_message` discards `tokens_used`.** This affects `!context` which needs tokens.

**Resolution:** In `RealPlatformClient.stream_message()`, after iterating through `send_message()`, `tokens_used` won't be directly available. Options:
1. Store `tokens_used` in a shared attribute after each response (add `self._last_tokens_used` to `AgentApi` or a wrapper).
2. Use the `callback` parameter to capture `MsgEventEnd` events from the `_listen` loop.

[ASSUMED] The simplest approach: wrap `AgentApi` in a thin `AgentApiAdapter` class that intercepts `_listen` output and exposes `last_tokens_used`. Or: store tokens in `PrototypeStateStore` after each message.

### AgentApi concurrency constraint (verified)

`AgentApi._request_lock` prevents parallel `send_message()` calls — second call raises `AgentBusyException`. In the single-user Matrix prototype this is acceptable. The bot must not dispatch two messages concurrently to the same agent.

### Wiring AgentApi into MatrixBot (integration pattern)

The `AgentApi` must be a persistent connection (not per-message connect/disconnect) because:
1. `_listen()` task runs in background and routes server push events.
2. Per-message connect/disconnect would recreate the aiohttp session each time and discard LangGraph thread state.

**Recommended wiring:**

```python
# adapter/matrix/bot.py — main() function
agent_api = AgentApi(agent_id="matrix-bot", url=ws_url)
await agent_api.connect()
runtime = build_runtime(store=SQLiteStore(db_path), client=client, agent_api=agent_api)
try:
    await client.sync_forever(timeout=30000, since=since_token)
finally:
    await client.close()
    await agent_api.close()
```

`_build_platform_from_env()` currently instantiates everything synchronously. It must be refactored to `async` or split: construct `AgentApi` synchronously, call `connect()` in `main()` before starting sync loop.

### RealPlatformClient updates

`RealPlatformClient` currently imports `AgentSessionClient` and calls `build_thread_key`. Both are removed. The updated class:

```python
class RealPlatformClient(PlatformClient):
    def __init__(
        self,
        agent_api: AgentApi,           # replaces agent_sessions: AgentSessionClient
        prototype_state: PrototypeStateStore,
        platform: str = "matrix",
    ) -> None:
```

`send_message()` and `stream_message()` call `agent_api.send_message(text)` directly — no `thread_key` needed.

### platform-agent origin/main: what changes (verified)

Our patch `1dca2c1` added `thread_id` query param handling to `external/platform-agent/src/api/external.py`. On `origin/main`, the `process_message()` function does NOT use `thread_id` — it calls `agent_service.astream(msg.text)` without `thread_id`. The WS URL becomes simply `ws://host:port/agent_ws/` — no query params.

### Existing command registration pattern (verified)

```python
# adapter/matrix/handlers/__init__.py — register_matrix_handlers()
dispatcher.register(IncomingCommand, "new",      make_handle_new_chat(client, store))
dispatcher.register(IncomingCommand, "settings", handle_settings)
dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store))
```

Handler signature (all existing handlers follow this):
```python
async def handle_X(
    event: IncomingCommand,
    auth_mgr,
    platform,
    chat_mgr,
    settings_mgr,
) -> list[OutgoingEvent]:
```

New context commands need access to `agent_api` (for `!save`, `!load`) and `store` (for `!context`, `!load` pending state). Pattern: use `make_handle_X(agent_api, store)` closures — same as `make_handle_new_chat(client, store)`.

### !load pending state pattern (verified)

Existing `PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"` in `adapter/matrix/store.py`.

New key for load pending state:
```python
LOAD_PENDING_PREFIX = "matrix_load_pending:"

def _load_pending_key(user_id: str, room_id: str) -> str:
    return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}"
```

Stored data structure:
```python
{
    "saves": [{"name": "my-save", "ts": "2026-04-16T12:00:00Z"}, ...],
    "display": "1. my-save (2026-04-16)\n2. other..."
}
```

The numeric input `1`, `2`, etc. is intercepted in `MatrixBot.on_room_message()` BEFORE dispatching as `IncomingMessage` — check if `load_pending` exists for this user+room, resolve to save name, dispatch the load command internally.

**Alternative (recommended):** Handle numeric input in the `IncomingMessage` handler via a pre-dispatch interceptor, or register a special numeric-input check in the dispatcher for messages that are pure integers.

### !reset confirmation dialog pattern

!reset reuses the `OutgoingUI` + `pending_confirm` mechanism or a simpler custom state. Since the dialog options are `!yes`, `!save имя`, `!no` (not just yes/no), it cannot reuse `pending_confirm` directly without extension.

Simplest approach: store `reset_pending:{user_id}:{room_id}` key (boolean) and check for `!yes`/`!no`/`!save` commands from the `IncomingCommand` dispatcher when reset_pending is set.

### saved sessions storage in PrototypeStateStore

New dict attribute on `PrototypeStateStore`:
```python
self._saved_sessions: dict[str, list[dict]] = {}
# Key: matrix_user_id
# Value: [{"name": "my-save", "created_at": "2026-04-16T12:00:00Z"}, ...]
```

Methods to add:
```python
async def add_saved_session(self, user_id: str, name: str) -> None: ...
async def list_saved_sessions(self, user_id: str) -> list[dict]: ...
```

### !context tokens_used tracking

`MsgEventEnd.tokens_used: int` is available from `server.py`. Since `AgentApi.send_message()` drops it, the planner must decide how to surface it. Recommended: store in `PrototypeStateStore` as `_last_tokens_used: dict[str, int]` keyed by user_id, updated after each successful agent response in `RealPlatformClient`.

### Prompts for !save / !load (Claude's Discretion)

```python
# !save
SAVE_PROMPT = (
    "Summarize our conversation and save to /workspace/contexts/{name}.md. "
    "Reply only with: Saved: {name}"
)

# !load
LOAD_PROMPT = (
    "Load context from /workspace/contexts/{name}.md and use it as background "
    "for our conversation. Reply: Loaded: {name}"
)
```

Auto-name format (Claude's Discretion): `context-{YYYYMMDD-HHMMSS}` (UTC, no spaces, no special chars, safe as filename).

### POST /reset endpoint

Confirmed absent in `origin/main` platform-agent. Only endpoint is `GET /agent_ws/` (WebSocket). The `main.py` has no HTTP routes beyond what FastAPI provides by default (`/docs`, `/openapi.json`).

`!reset` with `!yes` → `POST {AGENT_BASE_URL}/reset` → expect 404 → return "Reset endpoint недоступен. Обратитесь к администратору."

HTTP client for this: **httpx** (already in `pyproject.toml`):
```python
import httpx
async with httpx.AsyncClient() as client:
    response = await client.post(f"{agent_base_url}/reset", timeout=5.0)
    if response.status_code == 404:
        return [OutgoingMessage(chat_id=..., text="Reset endpoint недоступен...")]
```

### Dockerfile

```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install -e .
COPY . .
ENV PYTHONUNBUFFERED=1
CMD ["python", "-m", "adapter.matrix.bot"]
```

`lambda_agent_api` must be installed in the container. Options:
1. `COPY external/platform-agent_api /app/external/platform-agent_api` + `pip install -e /app/external/platform-agent_api`
2. Include `lambda_agent_api` package directly in `surfaces-bot` package (copy source files)

Option 1 is cleaner.

### docker-compose.yml structure

```yaml
services:
  matrix-bot:
    build: .
    env_file: .env
    restart: unless-stopped
```

Platform-agent runs separately — not in this compose file.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| WebSocket lifecycle with reconnect | Custom WS manager | `AgentApi` from `lambda_agent_api` | Already handles connect/close/listen loop, error routing, queue management |
| Message deserialization | Custom JSON parsing | `ServerMessage.validate_json()` (Pydantic TypeAdapter) | Discriminated union handles all message types |
| HTTP async client | `aiohttp.ClientSession` directly | `httpx.AsyncClient` | Already in deps, cleaner API for one-shot POST |
| Concurrent request guard | Custom lock | `AgentApi._request_lock` | Already implemented, raises `AgentBusyException` |

---

## Common Pitfalls

### Pitfall 1: lambda_agent_api Python version mismatch

**What goes wrong:** `lambda_agent_api/pyproject.toml` declares `requires-python = ">=3.14"`. The surfaces-bot runs on Python 3.11+. If `pip install -e external/platform-agent_api` is run with Python 3.11 it may fail or emit warnings.

**Why it happens:** The `lambda_agent_api` was developed under Python 3.14 (seen in `.venv` path: `python3.14`). The code itself uses no 3.14-specific syntax — it is pure aiohttp + pydantic which run on 3.11.

**How to avoid:** Change `requires-python = ">=3.11"` in `external/platform-agent_api/pyproject.toml` before building the Docker image, or install with `--ignore-requires-python`. Alternatively, copy the three source files directly into the surfaces-bot package.

**Warning signs:** `pip install` failure with "requires Python >=3.14".

### Pitfall 2: AgentApi.send_message() drops MsgEventEnd (tokens_used lost)

**What goes wrong:** The generator yields only `MsgEventTextChunk` objects and breaks on `MsgEventEnd` without yielding it. Any downstream code that tries to get `tokens_used` from the iterator gets nothing.

**Why it happens:** The generator `break`s on `MsgEventEnd` (line 172 of agent_api.py) without yielding it. This is intentional for streaming UX but loses token info.

**How to avoid:** Before streaming, set `self._last_tokens_used = 0`. In `_listen()`, `MsgEventEnd` is put into `_current_queue` (line 241). The `send_message()` generator reads from that queue but does `break` — the `MsgEventEnd` object is consumed but not returned to caller. The only way to capture it is to subclass `AgentApi` or read from `_current_queue` directly before the break.

**Practical fix:** Add `self.last_tokens_used: int = 0` to `AgentApi` and intercept the queue in the `finally` block of `send_message()` — or store it in a wrapper class.

### Pitfall 3: AgentApi persistent connection vs sync_forever loop

**What goes wrong:** If `agent_api.connect()` is called inside `_build_platform_from_env()` (sync function), it creates an `asyncio.Task` for `_listen()` outside the event loop context.

**Why it happens:** `_build_platform_from_env()` is called synchronously from `build_runtime()`. `connect()` is a coroutine.

**How to avoid:** Do NOT call `agent_api.connect()` inside `_build_platform_from_env()`. Instead:
1. `_build_platform_from_env()` creates `RealPlatformClient` with an unconnected `AgentApi`
2. `main()` awaits `agent_api.connect()` explicitly after constructing runtime

Expose `agent_api` from `RealPlatformClient` via a property so `main()` can call `connect()` on it.

### Pitfall 4: !load numeric input interception

**What goes wrong:** When user types `1` in response to `!load` menu, it is dispatched as `IncomingMessage` (not a command) and routed to the platform — the agent receives "1" as a user message.

**Why it happens:** The Matrix converter (`from_room_event`) produces `IncomingMessage` for plain text, `IncomingCommand` only for `!`-prefixed text.

**How to avoid:** In `MatrixBot.on_room_message()`, before calling `dispatcher.dispatch()`, check if `load_pending` state exists for this user+room. If yes and the message text is a digit (or `0`/`!cancel`), handle it as a load selection instead of routing to agent.

### Pitfall 5: platform-agent thread_id removal breaks existing tests

**What goes wrong:** `tests/platform/test_agent_session.py` imports `build_thread_key` and tests `process_message` with `thread_id` in query params. After the patch is removed, these tests will fail.

**Why it happens:** Tests were written against our patched `external.py`.

**How to avoid:** The plan must include updating `test_agent_session.py` — remove `build_thread_key` tests, update `process_message` tests to reflect origin/main signature (no `thread_id` param).

### Pitfall 6: !reset dialog conflicts with existing !yes/!no flow

**What goes wrong:** The existing `pending_confirm` flow uses `!yes`/`!no`. If both `reset_pending` and `pending_confirm` are active simultaneously, `!yes` could trigger the wrong handler.

**Why it happens:** Both flows listen for the same commands.

**How to avoid:** `!reset` dialog uses a separate state key `reset_pending:{user_id}:{room_id}`. The handler for `!yes` must check `reset_pending` first, then `pending_confirm`. Document priority in handler code.

---

## Code Examples

### Invoking AgentApi.send_message() in stream_message
```python
# Source: external/platform-agent_api/lambda_agent_api/agent_api.py
async def stream_message(self, user_id: str, chat_id: str, text: str, ...) -> AsyncIterator[MessageChunk]:
    async for event in self._agent_api.send_message(text):
        if isinstance(event, MsgEventTextChunk):
            yield MessageChunk(
                message_id=user_id,
                delta=event.text,
                finished=False,
            )
    # After loop ends, MsgEventEnd was consumed internally
    yield MessageChunk(message_id=user_id, delta="", finished=True, tokens_used=self._agent_api.last_tokens_used)
```

### Handler registration pattern
```python
# Source: adapter/matrix/handlers/__init__.py
def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None, agent_api=None) -> None:
    # existing...
    dispatcher.register(IncomingCommand, "save",    make_handle_save(agent_api, store))
    dispatcher.register(IncomingCommand, "load",    make_handle_load(agent_api, store))
    dispatcher.register(IncomingCommand, "reset",   make_handle_reset(store))
    dispatcher.register(IncomingCommand, "context", make_handle_context(store))
```

### !load pending key
```python
# New in adapter/matrix/store.py
LOAD_PENDING_PREFIX = "matrix_load_pending:"

async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None:
    return await store.get(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}")

async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None:
    await store.set(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}", data)

async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None:
    await store.delete(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}")
```

### platform-agent origin/main process_message (no thread_id)
```python
# Source: git show origin/main:src/api/external.py in external/platform-agent
async def process_message(ws: WebSocket, msg, agent_service: AgentService):
    match msg:
        case MsgUserMessage():
            async for chunk in agent_service.astream(msg.text):  # no thread_id arg
                await ws.send_text(chunk.model_dump_json())
            await ws.send_text(MsgEventEnd(tokens_used=0).model_dump_json())
```

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `tokens_used` can be captured by storing in `AgentApi.last_tokens_used` attribute during `_listen()` before it's queued | Architecture Patterns | If `_listen` timing means value is read before queue, token count would be wrong — low risk, easy to test |
| A2 | Python 3.11 can run `lambda_agent_api` despite `>=3.14` constraint in pyproject.toml | Standard Stack | If code uses 3.14-specific syntax, would fail at runtime — actual code inspected: no 3.14 syntax found |
| A3 | httpx is preferred over aiohttp for POST /reset (one-shot HTTP) | Standard Stack | Either works; httpx already in deps |

---

## Open Questions

1. **tokens_used capture from AgentApi**
   - What we know: `MsgEventEnd.tokens_used` is put into `_current_queue` but consumed (not yielded) by `send_message()` generator
   - What's unclear: Cleanest interception point without modifying `lambda_agent_api` source
   - Recommendation: Add `last_tokens_used: int = 0` attribute to `AgentApi` and set it in `send_message()`'s `finally` block when draining orphan queue, OR set it in `_listen()` before putting `MsgEventEnd` in queue

2. **!load numeric input dispatch**
   - What we know: Plain text `1`, `2` arrives as `IncomingMessage`, not `IncomingCommand`
   - What's unclear: Where to intercept — in `on_room_message()` (bot layer) or in dispatcher pre-hook
   - Recommendation: Intercept in `MatrixBot.on_room_message()` before `dispatcher.dispatch()`. Keeps dispatcher clean.

3. **lambda_agent_api install in Docker**
   - What we know: It's a local package in `external/platform-agent_api/`
   - What's unclear: Whether to install as editable or copy sources
   - Recommendation: `COPY external/platform-agent_api /build/lambda_agent_api && pip install /build/lambda_agent_api` in Dockerfile

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|-------------|-----------|---------|----------|
| Python 3.11+ | All | ✓ | System | — |
| aiohttp | AgentApi WS | ✓ | >=3.9 in deps | — |
| httpx | POST /reset | ✓ | >=0.27 in deps | aiohttp |
| matrix-nio | Matrix bot | ✓ | >=0.21 in deps | — |
| lambda_agent_api | AgentApi | local only | 0.1.0 | — |
| Docker | Container build | [ASSUMED] standard dev env | — | — |
| platform-agent (running) | Integration test | local clone | origin/main needed | — |

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest + pytest-asyncio (asyncio_mode = "auto") |
| Config file | pyproject.toml `[tool.pytest.ini_options]` |
| Quick run command | `pytest tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v` |
| Full suite command | `pytest tests/ -v` |

### Phase Requirements → Test Map

| Req | Behavior | Test Type | File |
|-----|----------|-----------|------|
| Remove build_thread_key | Function gone from sdk/ | unit | `tests/platform/test_agent_session.py` — update/remove |
| AgentApi replaces AgentSessionClient | `RealPlatformClient` uses `AgentApi` | unit | `tests/platform/test_real.py` — update |
| !save sends prompt to agent | Command dispatches agent message | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
| !load shows list | Command returns numbered list | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
| !load numeric select | Bot intercepts digit, sends load prompt | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
| !reset shows dialog | Command returns confirmation UI | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
| !context returns snapshot | Command returns session info | unit | `tests/adapter/matrix/test_dispatcher.py` — add |
| PrototypeStateStore saved sessions | add/list saved sessions | unit | `tests/platform/test_prototype_state.py` — add |

### Wave 0 Gaps
- [ ] `tests/platform/test_agent_api_integration.py` — unit tests for `RealPlatformClient` with mocked `AgentApi`
- [ ] `tests/adapter/matrix/test_context_commands.py` — dedicated module for !save/!load/!reset/!context handlers

---

## Sources

### Primary (HIGH confidence — verified by file read in this session)
- `external/platform-agent_api/lambda_agent_api/agent_api.py` — AgentApi constructor, connect/close/send_message, _listen loop
- `external/platform-agent_api/lambda_agent_api/server.py` — MsgEventTextChunk, MsgEventEnd, MsgStatus, AgentEventUnion types
- `external/platform-agent_api/lambda_agent_api/client.py` — MsgUserMessage type
- `external/platform-agent/src/api/external.py` — current (patched) and origin/main versions verified via git show
- `adapter/matrix/handlers/__init__.py` — handler registration pattern
- `adapter/matrix/store.py` — pending_confirm key pattern
- `adapter/matrix/bot.py` — MatrixBot, build_runtime, _build_platform_from_env
- `sdk/agent_session.py` — current AgentSessionClient (to be replaced)
- `sdk/real.py` — RealPlatformClient (to be updated)
- `sdk/prototype_state.py` — PrototypeStateStore (to be extended)
- `core/protocol.py` — IncomingCommand, OutgoingMessage types
- `pyproject.toml` — dependency versions
- `external/platform-agent_api/pyproject.toml` — Python version constraint

### Tertiary (LOW confidence)
- Docker best practices for Python apps [ASSUMED] — standard industry pattern

---

## Metadata

**Confidence breakdown:**
- AgentApi interface: HIGH — read source directly
- platform-agent origin/main diff: HIGH — verified via `git show origin/main`
- handler registration pattern: HIGH — read all handler files
- pending_confirm key pattern: HIGH — read store.py directly
- tokens_used interception: MEDIUM — pattern clear but implementation needs care
- Docker/docker-compose: MEDIUM — standard pattern, not verified against specific matrix-nio requirements

**Research date:** 2026-04-16
**Valid until:** 2026-05-16 (lambda_agent_api is local — stable until platform team updates it)
