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