# Matrix Multi-Agent Routing And Restart State Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart. **Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart. **Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio --- ## File Structure - Create: `adapter/matrix/agent_registry.py` Purpose: load and validate the YAML agent registry used by Matrix runtime. - Create: `adapter/matrix/routed_platform.py` Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances. - Create: `adapter/matrix/handlers/agent.py` Purpose: implement `!agent` listing and selection behavior. - Create: `tests/adapter/matrix/test_agent_registry.py` Purpose: cover YAML loading and registry validation. - Create: `tests/adapter/matrix/test_routed_platform.py` Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol. - Create: `tests/adapter/matrix/test_agent_handler.py` Purpose: cover `!agent` UX and persistence of `selected_agent_id`. - Create: `tests/adapter/matrix/test_restart_persistence.py` Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite. - Create: `config/matrix-agents.example.yaml` Purpose: document the expected agent registry format. - Modify: `pyproject.toml` Purpose: add YAML parsing dependency required by the runtime registry loader. - Modify: `.env.example` Purpose: document the config path env var for the Matrix agent registry. - Modify: `README.md` Purpose: document the new config file, `!agent`, and restart persistence expectations. - Modify: `adapter/matrix/store.py` Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics. - Modify: `adapter/matrix/bot.py` Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch. - Modify: `adapter/matrix/handlers/__init__.py` Purpose: register the new `!agent` command. - Modify: `adapter/matrix/handlers/chat.py` Purpose: require a selected agent for `!new` and bind new rooms to that agent. - Modify: `adapter/matrix/handlers/context_commands.py` Purpose: keep context commands compatible with local chat ids and routed platform delegation. - Modify: `adapter/matrix/handlers/settings.py` Purpose: expose `!agent` in help text. - Modify: `tests/adapter/matrix/test_dispatcher.py` Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics. - Modify: `tests/adapter/matrix/test_context_commands.py` Purpose: keep load/reset/context flows aligned with the routed platform facade. --- ### Task 1: Add The Agent Registry And Configuration Wiring **Files:** - Create: `adapter/matrix/agent_registry.py` - Create: `tests/adapter/matrix/test_agent_registry.py` - Create: `config/matrix-agents.example.yaml` - Modify: `pyproject.toml` - Modify: `.env.example` - Modify: `README.md` - [ ] **Step 1: Write the failing registry tests** ```python # tests/adapter/matrix/test_agent_registry.py from pathlib import Path import pytest from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry def test_load_agent_registry_reads_yaml_entries(tmp_path: Path): path = tmp_path / "agents.yaml" path.write_text( "agents:\n" " - id: agent-1\n" " label: Analyst\n" " - id: agent-2\n" " label: Research\n", encoding="utf-8", ) registry = load_agent_registry(path) assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"] assert registry.get("agent-1").label == "Analyst" def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path): path = tmp_path / "agents.yaml" path.write_text( "agents:\n" " - id: agent-1\n" " label: Analyst\n" " - id: agent-1\n" " label: Duplicate\n", encoding="utf-8", ) with pytest.raises(AgentRegistryError, match="duplicate agent id"): load_agent_registry(path) ``` - [ ] **Step 2: Run the registry tests to verify they fail** Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`. - [ ] **Step 3: Add the YAML dependency and implement the registry loader** ```toml # pyproject.toml dependencies = [ "aiogram>=3.4,<4", "matrix-nio>=0.21", "pydantic>=2.5", "structlog>=24.1", "python-dotenv>=1.0", "httpx>=0.27", "aiohttp>=3.9", "PyYAML>=6.0", ] ``` ```python # adapter/matrix/agent_registry.py from __future__ import annotations from dataclasses import dataclass from pathlib import Path import yaml class AgentRegistryError(ValueError): pass @dataclass(frozen=True) class AgentDefinition: agent_id: str label: str class AgentRegistry: def __init__(self, agents: list[AgentDefinition]) -> None: self.agents = agents self._by_id = {agent.agent_id: agent for agent in agents} def get(self, agent_id: str) -> AgentDefinition: try: return self._by_id[agent_id] except KeyError as exc: raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc def load_agent_registry(path: str | Path) -> AgentRegistry: raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} entries = raw.get("agents") if not isinstance(entries, list) or not entries: raise AgentRegistryError("agents registry must contain a non-empty agents list") agents: list[AgentDefinition] = [] seen: set[str] = set() for entry in entries: agent_id = str(entry.get("id", "")).strip() label = str(entry.get("label", "")).strip() if not agent_id or not label: raise AgentRegistryError("each agent entry requires id and label") if agent_id in seen: raise AgentRegistryError(f"duplicate agent id: {agent_id}") seen.add(agent_id) agents.append(AgentDefinition(agent_id=agent_id, label=label)) return AgentRegistry(agents) ``` - [ ] **Step 4: Add the example config and runtime wiring docs** ```yaml # config/matrix-agents.example.yaml agents: - id: agent-1 label: Analyst - id: agent-2 label: Research ``` ```env # .env.example MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml ``` ```markdown # README.md 1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml` 2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` 3. Use `!agent` in Matrix to select the active upstream agent ``` - [ ] **Step 5: Run the registry tests to verify they pass** Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py git commit -m "feat: add matrix agent registry loader" ``` --- ### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient` **Files:** - Create: `adapter/matrix/routed_platform.py` - Create: `tests/adapter/matrix/test_routed_platform.py` - Modify: `adapter/matrix/bot.py` - [ ] **Step 1: Write the failing routed-platform tests** ```python # tests/adapter/matrix/test_routed_platform.py import pytest from adapter.matrix.routed_platform import RoutedPlatformClient from adapter.matrix.store import set_room_meta from core.chat import ChatManager from core.store import InMemoryStore from sdk.interface import MessageResponse from sdk.prototype_state import PrototypeStateStore class FakeDelegate: def __init__(self, agent_id: str) -> None: self.agent_id = agent_id self.calls = [] async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): self.calls.append((user_id, chat_id, text, attachments)) return MessageResponse( message_id=user_id, response=f"{self.agent_id}:{text}", tokens_used=0, finished=True, ) async def get_or_create_user(self, external_id: str, platform: str, display_name=None): return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name) async def get_settings(self, user_id: str): return await PrototypeStateStore().get_settings(user_id) async def update_settings(self, user_id: str, action): return None @pytest.mark.asyncio async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id(): store = InMemoryStore() chat_mgr = ChatManager(None, store) await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") await set_room_meta( store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, ) delegates = {"agent-2": FakeDelegate("agent-2")} platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) response = await platform.send_message("u1", "C1", "hello") assert response.response == "agent-2:hello" assert delegates["agent-2"].calls == [("u1", "41", "hello", None)] ``` - [ ] **Step 2: Run the routed-platform tests to verify they fail** Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` Expected: FAIL with `ImportError` for `RoutedPlatformClient`. - [ ] **Step 3: Implement the routing facade and integrate runtime construction** ```python # adapter/matrix/routed_platform.py from __future__ import annotations from sdk.interface import PlatformClient class RoutedPlatformClient(PlatformClient): def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None: self._store = store self._chat_mgr = chat_mgr self._delegates = delegates async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]: ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id) if ctx is None: raise ValueError(f"Chat {local_chat_id} not found for {user_id}") room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}") if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"): raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target") delegate = self._delegates[room_meta["agent_id"]] return delegate, str(room_meta["platform_chat_id"]) async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) return await delegate.send_message(user_id, platform_chat_id, text, attachments) async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None): delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments): yield chunk async def get_or_create_user(self, external_id: str, platform: str, display_name=None): first_delegate = next(iter(self._delegates.values())) return await first_delegate.get_or_create_user(external_id, platform, display_name) async def get_settings(self, user_id: str): first_delegate = next(iter(self._delegates.values())) return await first_delegate.get_settings(user_id) async def update_settings(self, user_id: str, action): first_delegate = next(iter(self._delegates.values())) await first_delegate.update_settings(user_id, action) ``` ```python # adapter/matrix/bot.py from adapter.matrix.agent_registry import load_agent_registry from adapter.matrix.routed_platform import RoutedPlatformClient def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient: backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() if backend != "real": return MockPlatformClient() registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"]) delegates = { agent.agent_id: RealPlatformClient( agent_id=agent.agent_id, agent_base_url=_agent_base_url_from_env(), prototype_state=PrototypeStateStore(), platform="matrix", ) for agent in registry.agents } return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) def build_runtime(...): store = store or InMemoryStore() chat_mgr = ChatManager(None, store) platform = platform or _build_platform_from_env(store, chat_mgr) auth_mgr = AuthManager(platform, store) settings_mgr = SettingsManager(platform, store) dispatcher = EventDispatcher( platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr, ) ``` - [ ] **Step 4: Run the routed-platform tests to verify they pass** Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py git commit -m "feat: add matrix routed platform facade" ``` --- ### Task 3: Add `!agent` Selection And Durable User Agent State **Files:** - Create: `adapter/matrix/handlers/agent.py` - Create: `tests/adapter/matrix/test_agent_handler.py` - Modify: `adapter/matrix/store.py` - Modify: `adapter/matrix/handlers/__init__.py` - Modify: `adapter/matrix/handlers/settings.py` - [ ] **Step 1: Write the failing agent-handler tests** ```python # tests/adapter/matrix/test_agent_handler.py import pytest from adapter.matrix.handlers.agent import make_handle_agent from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta from core.protocol import IncomingCommand from core.store import InMemoryStore class FakeRegistry: def __init__(self) -> None: self.agents = [ type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(), type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(), ] @pytest.mark.asyncio async def test_agent_command_lists_available_agents(): handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry()) result = await handler( IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]), None, None, None, None, ) assert "1. Analyst" in result[0].text assert "2. Research" in result[0].text @pytest.mark.asyncio async def test_agent_command_persists_selected_agent_and_binds_unbound_room(): store = InMemoryStore() await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"}) handler = make_handle_agent(store=store, registry=FakeRegistry()) chat_mgr = type( "ChatMgr", (), {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())}, )() await handler( IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]), None, None, chat_mgr, None, ) assert await get_selected_agent_id(store, "u1") == "agent-2" room_meta = await get_room_meta(store, "!room:example.org") assert room_meta["agent_id"] == "agent-2" ``` - [ ] **Step 2: Run the agent-handler tests to verify they fail** Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` Expected: FAIL with missing handler or store helpers. - [ ] **Step 3: Add durable store helpers and implement `!agent`** ```python # adapter/matrix/store.py async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None: meta = await get_user_meta(store, matrix_user_id) or {} value = meta.get("selected_agent_id") return str(value) if value else None async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None: meta = await get_user_meta(store, matrix_user_id) or {} meta["selected_agent_id"] = agent_id await set_user_meta(store, matrix_user_id, meta) async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None: meta = dict(await get_room_meta(store, room_id) or {}) meta["agent_id"] = agent_id await set_room_meta(store, room_id, meta) ``` ```python # adapter/matrix/handlers/agent.py from __future__ import annotations from adapter.matrix.store import ( get_room_meta, get_selected_agent_id, next_platform_chat_id, set_platform_chat_id, set_room_agent_id, set_selected_agent_id, ) from core.protocol import IncomingCommand, OutgoingMessage def make_handle_agent(store, registry): async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): if not event.args: current = await get_selected_agent_id(store, event.user_id) lines = ["Доступные агенты:"] for index, agent in enumerate(registry.agents, start=1): marker = " (текущий)" if agent.agent_id == current else "" lines.append(f"{index}. {agent.label}{marker}") lines.append("") lines.append("Выбери агента: !agent <номер>") return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] agent = registry.agents[int(event.args[0]) - 1] await set_selected_agent_id(store, event.user_id, agent.agent_id) ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None if ctx is not None: room_meta = await get_room_meta(store, ctx.surface_ref) if room_meta is not None and not room_meta.get("agent_id"): await set_room_agent_id(store, ctx.surface_ref, agent.agent_id) if not room_meta.get("platform_chat_id"): await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")] return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")] return handle_agent ``` - [ ] **Step 4: Register the command and update help text** ```python # adapter/matrix/handlers/__init__.py from adapter.matrix.handlers.agent import make_handle_agent dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) ``` ```python # adapter/matrix/handlers/settings.py HELP_TEXT = "\n".join( [ "Команды", "", "!agent выбрать активного агента", "!new [название] создать новый чат", "!chats список активных чатов", "!rename <название> переименовать текущий чат", "!archive архивировать текущий чат", "!context показать текущее состояние контекста", "!save [имя] сохранить текущий контекст", "!load показать сохранённые контексты", ] ) ``` - [ ] **Step 5: Run the agent-handler tests to verify they pass** Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py git commit -m "feat: add matrix agent selection command" ``` --- ### Task 4: Bind Rooms Correctly And Block Stale Chats **Files:** - Modify: `adapter/matrix/bot.py` - Modify: `adapter/matrix/handlers/chat.py` - Modify: `adapter/matrix/handlers/context_commands.py` - Modify: `tests/adapter/matrix/test_dispatcher.py` - Modify: `tests/adapter/matrix/test_context_commands.py` - [ ] **Step 1: Write the failing dispatcher and context-command tests** ```python # tests/adapter/matrix/test_dispatcher.py @pytest.mark.asyncio async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent(): runtime = build_runtime(platform=MockPlatformClient()) client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) bot = MatrixBot(client, runtime) await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"}) await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello")) client.room_send.assert_awaited_once() assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower() @pytest.mark.asyncio async def test_new_chat_requires_selected_agent_and_binds_room_meta(): client = SimpleNamespace( room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), room_put_state=AsyncMock(), ) runtime = build_runtime(platform=MockPlatformClient(), client=client) await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"}) result = await runtime.dispatcher.dispatch( IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"]) ) room_meta = await get_room_meta(runtime.store, "!r2:example") assert room_meta["agent_id"] == "agent-2" assert "Создан чат" in result[0].text ``` ```python # tests/adapter/matrix/test_context_commands.py @pytest.mark.asyncio async def test_load_selection_calls_platform_with_local_chat_id(): platform = MatrixCommandPlatform() runtime = build_runtime(platform=platform) await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}) client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) bot = MatrixBot(client, runtime) await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]}) await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1")) platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a")) ``` - [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail** Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`. - [ ] **Step 3: Implement room binding and stale-room checks in runtime** ```python # adapter/matrix/bot.py from adapter.matrix.store import ( get_selected_agent_id, get_room_meta, next_platform_chat_id, set_platform_chat_id, set_room_agent_id, ) async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]: room_meta = await get_room_meta(self.runtime.store, room_id) selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id) if not selected_agent_id: return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.") if room_meta is None: return room_meta, None if not room_meta.get("agent_id"): await set_room_agent_id(self.runtime.store, room_id, selected_agent_id) if not room_meta.get("platform_chat_id"): await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store)) room_meta = await get_room_meta(self.runtime.store, room_id) return room_meta, None if room_meta["agent_id"] != selected_agent_id: return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.") return room_meta, None ``` ```python # adapter/matrix/bot.py local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender) dispatch_chat_id = local_chat_id if not body.startswith("!"): room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender) if blocking is not None: await self._send_all(room.room_id, [blocking]) return incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) ``` - [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`** ```python # adapter/matrix/handlers/chat.py from adapter.matrix.store import get_selected_agent_id selected_agent_id = await get_selected_agent_id(store, event.user_id) if not selected_agent_id: return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")] await set_room_meta( store, room_id, { "room_type": "chat", "chat_id": chat_id, "display_name": room_name, "matrix_user_id": event.user_id, "space_id": space_id, "platform_chat_id": platform_chat_id, "agent_id": selected_agent_id, }, ) ``` ```python # adapter/matrix/bot.py room_meta = await get_room_meta(self.runtime.store, room_id) local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id await self.runtime.platform.send_message( user_id, local_chat_id, LOAD_PROMPT.format(name=name), ) ``` - [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass** Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py git commit -m "feat: bind matrix rooms to selected agents" ``` --- ### Task 5: Prove Durable Restart State And Sequence Persistence **Files:** - Create: `tests/adapter/matrix/test_restart_persistence.py` - Modify: `adapter/matrix/store.py` - Modify: `README.md` - [ ] **Step 1: Write the failing restart-persistence tests** ```python # tests/adapter/matrix/test_restart_persistence.py import pytest from adapter.matrix.store import ( get_selected_agent_id, next_platform_chat_id, set_room_meta, set_selected_agent_id, ) from core.store import SQLiteStore @pytest.mark.asyncio async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path): db_path = tmp_path / "matrix.db" store = SQLiteStore(str(db_path)) await set_selected_agent_id(store, "u1", "agent-2") await set_room_meta( store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, ) reopened = SQLiteStore(str(db_path)) assert await get_selected_agent_id(reopened, "u1") == "agent-2" assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2" assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41" @pytest.mark.asyncio async def test_platform_chat_sequence_survives_store_recreation(tmp_path): db_path = tmp_path / "matrix.db" store = SQLiteStore(str(db_path)) assert await next_platform_chat_id(store) == "1" assert await next_platform_chat_id(store) == "2" reopened = SQLiteStore(str(db_path)) assert await next_platform_chat_id(reopened) == "3" ``` - [ ] **Step 2: Run the restart-persistence tests to verify they fail** Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered. - [ ] **Step 3: Make sequence persistence explicit and document the restart boundary** ```python # adapter/matrix/store.py PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq" async def next_platform_chat_id(store: StateStore) -> str: async with _PLATFORM_CHAT_SEQ_LOCK: data = await store.get(PLATFORM_CHAT_SEQ_KEY) index = int((data or {}).get("next_platform_chat_index", 1)) await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1}) return str(index) ``` ```markdown # README.md - Matrix durable state lives in `lambda_matrix.db` and `matrix_store` - normal restart is supported only when those paths survive container recreation - staged attachments and pending confirmations are intentionally not restored ``` - [ ] **Step 4: Run the restart-persistence tests to verify they pass** Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` Expected: PASS - [ ] **Step 5: Run the combined verification sweep** Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q` Expected: PASS - [ ] **Step 6: Commit** ```bash git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py git commit -m "test: cover matrix restart state persistence" ``` --- ## Self-Review ### Spec coverage - Multi-agent agent registry: Task 1 - Shared `PlatformClient` preserved via routing facade: Task 2 - `!agent` UX and durable `selected_agent_id`: Task 3 - Unbound room activation, `!new`, stale room rejection: Task 4 - Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5 ### Placeholder scan - No `TODO`, `TBD`, or “implement later” markers remain. - Each task includes exact file paths, tests, commands, and minimal code snippets. ### Type consistency - `selected_agent_id` lives in user metadata throughout the plan. - `agent_id` and `platform_chat_id` live in room metadata throughout the plan. - `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.