--- phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma plan: 02 type: execute wave: 2 depends_on: - 04-01-PLAN.md files_modified: - sdk/prototype_state.py - adapter/matrix/store.py - adapter/matrix/handlers/__init__.py - adapter/matrix/handlers/context_commands.py - adapter/matrix/bot.py - tests/adapter/matrix/test_context_commands.py - tests/platform/test_prototype_state.py autonomous: true requirements: - Implement !save, !load, !reset, !context commands - PrototypeStateStore saved sessions storage - !load pending state in Matrix store - !reset pending state in Matrix store - Numeric input interception for !load must_haves: truths: - "!save sends a save prompt to the agent and records session name in PrototypeStateStore" - "!load shows a numbered list of saved sessions; numeric reply selects a session" - "!reset shows a confirmation dialog; !yes calls POST /reset; !no cancels" - "!context returns current session name, last tokens_used, and list of saved sessions" - "Numeric input intercepted in on_room_message before dispatcher.dispatch when load_pending is set" - "!yes in reset_pending context calls POST {AGENT_BASE_URL}/reset and reports unavailable on 404" - "All context command tests pass" artifacts: - path: "adapter/matrix/handlers/context_commands.py" provides: "make_handle_save, make_handle_load, make_handle_reset, make_handle_context" - path: "adapter/matrix/store.py" provides: "get_load_pending, set_load_pending, clear_load_pending, get_reset_pending, set_reset_pending, clear_reset_pending" - path: "sdk/prototype_state.py" provides: "add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used" - path: "tests/adapter/matrix/test_context_commands.py" provides: "tests for all four commands" key_links: - from: "adapter/matrix/bot.py on_room_message()" to: "adapter/matrix/store.get_load_pending()" via: "check before dispatcher.dispatch" pattern: "get_load_pending" - from: "adapter/matrix/handlers/context_commands.py make_handle_reset" to: "httpx.AsyncClient.post(AGENT_BASE_URL + '/reset')" via: "!yes handler inside reset_pending flow" pattern: "httpx" - from: "sdk/real.py stream_message()" to: "prototype_state.set_last_tokens_used()" via: "call after final chunk" pattern: "set_last_tokens_used" --- Add four context management commands to the Matrix bot: !save, !load, !reset, !context. Extend PrototypeStateStore with saved sessions and last_tokens_used tracking. Add load_pending and reset_pending state keys to Matrix store. Wire numeric input interception in on_room_message. Register all handlers. Purpose: Users need to save, load, and reset agent context, and inspect current context state — essential for a shared-context MVP where one agent container persists across Matrix sessions. Output: context_commands.py handler module, store.py extensions, prototype_state.py extensions, bot.py updated, full test coverage. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md @.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md @.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md From adapter/matrix/store.py (existing pattern): ```python PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str: ... async def get_pending_confirm(store, user_id, room_id=None) -> dict | None: ... async def set_pending_confirm(store, user_id, room_id, meta) -> None: ... async def clear_pending_confirm(store, user_id, room_id=None) -> None: ... ``` New store keys to add (same pattern): ```python LOAD_PENDING_PREFIX = "matrix_load_pending:" RESET_PENDING_PREFIX = "matrix_reset_pending:" # Keys: f"{PREFIX}{user_id}:{room_id}" # load_pending data: {"saves": [{"name": str, "created_at": str}, ...], "display": str} # reset_pending data: {"active": True} ``` From adapter/matrix/handlers/__init__.py (existing registration): ```python def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None: dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) ... ``` Handler closure signature (all existing handlers follow this): ```python async def handle_X(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]: ``` New handlers use make_handle_X(agent_api, store, prototype_state) closures: ```python async def _inner(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]: ... return _inner ``` From sdk/prototype_state.py (PrototypeStateStore to extend): ```python class PrototypeStateStore: def __init__(self) -> None: self._users: dict[str, User] = {} self._settings: dict[str, dict[str, Any]] = {} # Add: # self._saved_sessions: dict[str, list[dict]] = {} # self._last_tokens_used: dict[str, int] = {} ``` From core/protocol.py: ```python @dataclass class IncomingCommand: user_id: str; platform: str; chat_id: str; command: str; args: list[str] @dataclass class OutgoingMessage: chat_id: str; text: str @dataclass class OutgoingUI: chat_id: str; text: str; buttons: list[UIButton] ``` From sdk/real.py (after Plan 01): ```python class RealPlatformClient: async def stream_message(self, user_id, chat_id, text, ...) -> AsyncIterator[MessageChunk]: # yields chunks; last chunk has finished=True, tokens_used=agent_api.last_tokens_used ``` SAVE_PROMPT template (Claude's Discretion): ```python SAVE_PROMPT = ( "Summarize our conversation and save to /workspace/contexts/{name}.md. " "Reply only with: Saved: {name}" ) LOAD_PROMPT = ( "Load context from /workspace/contexts/{name}.md and use it as background " "for our conversation. Reply: Loaded: {name}" ) ``` Auto-name format for !save without args: `context-{YYYYMMDD-HHMMSS}` UTC. HTTP client for POST /reset: httpx.AsyncClient (already in pyproject.toml deps). AGENT_BASE_URL env var: `os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")` Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers - sdk/prototype_state.py (full file — adding saved_sessions and last_tokens_used) - adapter/matrix/store.py (full file — adding load_pending and reset_pending helpers) - tests/platform/test_prototype_state.py (full file — adding new test cases) sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py - PrototypeStateStore.__init__ adds: self._saved_sessions: dict[str, list[dict]] = {} and self._last_tokens_used: dict[str, int] = {} - add_saved_session(user_id: str, name: str) -> None: appends {"name": name, "created_at": datetime.now(UTC).isoformat()} to _saved_sessions[user_id] - list_saved_sessions(user_id: str) -> list[dict]: returns copy of _saved_sessions.get(user_id, []) - get_last_tokens_used(user_id: str) -> int: returns _last_tokens_used.get(user_id, 0) - set_last_tokens_used(user_id: str, tokens: int) -> None: sets _last_tokens_used[user_id] = tokens - adapter/matrix/store.py adds LOAD_PENDING_PREFIX and RESET_PENDING_PREFIX constants - get_load_pending(store, user_id, room_id) -> dict | None - set_load_pending(store, user_id, room_id, data: dict) -> None - clear_load_pending(store, user_id, room_id) -> None - get_reset_pending(store, user_id, room_id) -> dict | None - set_reset_pending(store, user_id, room_id, data: dict) -> None - clear_reset_pending(store, user_id, room_id) -> None - test_prototype_state.py gets 4 new tests: add/list saved sessions, last_tokens_used get/set 1. Edit sdk/prototype_state.py — add to __init__ and add four new async methods: In __init__ after existing attributes: ```python self._saved_sessions: dict[str, list[dict]] = {} self._last_tokens_used: dict[str, int] = {} ``` After update_settings() method, add: ```python async def add_saved_session(self, user_id: str, name: str) -> None: sessions = self._saved_sessions.setdefault(user_id, []) sessions.append({"name": name, "created_at": datetime.now(UTC).isoformat()}) async def list_saved_sessions(self, user_id: str) -> list[dict]: return list(self._saved_sessions.get(user_id, [])) async def get_last_tokens_used(self, user_id: str) -> int: return self._last_tokens_used.get(user_id, 0) async def set_last_tokens_used(self, user_id: str, tokens: int) -> None: self._last_tokens_used[user_id] = tokens ``` 2. Edit adapter/matrix/store.py — add after existing constants and helpers: After PENDING_CONFIRM_PREFIX line, add: ```python LOAD_PENDING_PREFIX = "matrix_load_pending:" RESET_PENDING_PREFIX = "matrix_reset_pending:" ``` After clear_pending_confirm(), add: ```python def _load_pending_key(user_id: str, room_id: str) -> str: return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}" async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: return await store.get(_load_pending_key(user_id, room_id)) async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: await store.set(_load_pending_key(user_id, room_id), data) async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None: await store.delete(_load_pending_key(user_id, room_id)) def _reset_pending_key(user_id: str, room_id: str) -> str: return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}" async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: return await store.get(_reset_pending_key(user_id, room_id)) async def set_reset_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: await store.set(_reset_pending_key(user_id, room_id), data) async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None: await store.delete(_reset_pending_key(user_id, room_id)) ``` 3. Edit tests/platform/test_prototype_state.py — append four new tests: ```python @pytest.mark.asyncio async def test_saved_sessions_add_and_list(): store = PrototypeStateStore() await store.add_saved_session("u1", "my-save") await store.add_saved_session("u1", "another-save") sessions = await store.list_saved_sessions("u1") assert len(sessions) == 2 assert sessions[0]["name"] == "my-save" assert "created_at" in sessions[0] assert sessions[1]["name"] == "another-save" @pytest.mark.asyncio async def test_saved_sessions_list_returns_copy(): store = PrototypeStateStore() await store.add_saved_session("u1", "my-save") sessions = await store.list_saved_sessions("u1") sessions.append({"name": "injected"}) sessions2 = await store.list_saved_sessions("u1") assert len(sessions2) == 1 @pytest.mark.asyncio async def test_last_tokens_used_default_zero(): store = PrototypeStateStore() assert await store.get_last_tokens_used("u1") == 0 @pytest.mark.asyncio async def test_last_tokens_used_set_and_get(): store = PrototypeStateStore() await store.set_last_tokens_used("u1", 42) assert await store.get_last_tokens_used("u1") == 42 ``` cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15 - PrototypeStateStore has add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used - adapter/matrix/store.py has LOAD_PENDING_PREFIX, RESET_PENDING_PREFIX, and 6 new helper functions - All test_prototype_state.py tests pass (including 4 new ones) - `grep "add_saved_session\|list_saved_sessions" sdk/prototype_state.py` returns matches - `grep "LOAD_PENDING_PREFIX\|RESET_PENDING_PREFIX" adapter/matrix/store.py` returns matches Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py - adapter/matrix/handlers/__init__.py (full file — adding registrations) - adapter/matrix/handlers/confirm.py (full file — example of make_handle_X closure pattern with store) - adapter/matrix/bot.py (full file — on_room_message and build_runtime need changes) - sdk/real.py (after Plan 01 — add set_last_tokens_used call after stream_message) - adapter/matrix/store.py (after Task 1 — load_pending/reset_pending helpers now available) - sdk/prototype_state.py (after Task 1 — saved_sessions methods available) adapter/matrix/handlers/context_commands.py, adapter/matrix/handlers/__init__.py, adapter/matrix/bot.py, sdk/real.py, tests/adapter/matrix/test_context_commands.py - context_commands.py exports: make_handle_save, make_handle_load, make_handle_reset, make_handle_context - make_handle_save(agent_api, store, prototype_state) -> handler: !save with no args: auto-name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" !save [name]: use args[0] as name sends SAVE_PROMPT via platform.send_message (NOT stream — simple blocking send) calls prototype_state.add_saved_session(event.user_id, name) returns [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")] - make_handle_load(agent_api, store, prototype_state) -> handler: !load: fetches sessions = await prototype_state.list_saved_sessions(event.user_id) if empty: returns [OutgoingMessage(chat_id=..., text="Нет сохранённых сессий. Используй !save [имя].")] else: builds numbered display text, stores load_pending via set_load_pending(store, event.user_id, room_id, {"saves": sessions}) room_id is in event.chat_id (in Matrix adapter, chat_id == room_id for commands) returns [OutgoingMessage(chat_id=..., text=display_text + "\nВведи номер или 0 / !cancel для отмены.")] - Numeric input interception in MatrixBot.on_room_message(): Before dispatcher.dispatch, check load_pending = await get_load_pending(runtime.store, sender, room_id) If load_pending and msg text is digit: handle_load_selection(pending, selection, ...) handle_load_selection: if text == "0" or "!cancel" → clear_load_pending, return [OutgoingMessage("Отменено")] if valid index → clear_load_pending, send LOAD_PROMPT via platform.send_message, add session as current_session in prototype_state (store in dict), return [OutgoingMessage("Загрузка: {name}")] if invalid index → return [OutgoingMessage("Неверный номер. Введи от 1 до N или 0 для отмены.")] - make_handle_reset(store, agent_base_url) -> handler: !reset: set reset_pending, return [OutgoingMessage with text: "Сбросить контекст агента? Выбери:\n !yes — сбросить\n !save [имя] — сохранить и сбросить\n !no — отмена")] !yes in reset_pending: call POST {AGENT_BASE_URL}/reset via httpx; if 404 or connection error → "Reset endpoint недоступен. Обратитесь к администратору."; else "Контекст сброшен."; clear reset_pending !no in reset_pending: clear reset_pending, return [OutgoingMessage("Отменено.")] !save имя in reset_pending: delegate to save logic, then POST /reset (same fallback) Reset_pending check must happen BEFORE pending_confirm in handler priority — implement by checking reset_pending in the !yes and !no handlers (make_handle_confirm must check reset_pending first) - make_handle_context(store, prototype_state) -> handler: reads current_session from prototype_state._current_session dict (keyed by user_id) if it exists reads tokens = await prototype_state.get_last_tokens_used(event.user_id) reads sessions = await prototype_state.list_saved_sessions(event.user_id) formats: "Контекст:\n Сессия: {name or 'не загружена'}\n Токены (последний ответ): {tokens}\n Сохранения ({len}):\n {list}" returns [OutgoingMessage(chat_id=..., text=formatted)] - sdk/real.py: after the final yield in stream_message, call await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) — needs prototype_state reference already present in RealPlatformClient - PrototypeStateStore gets one more dict: self._current_session: dict[str, str] = {} and methods get_current_session(user_id) -> str | None, set_current_session(user_id, name) -> None - register_matrix_handlers() updated to accept agent_api and agent_base_url params; registers save/load/reset/context 1. Add to sdk/prototype_state.py __init__: `self._current_session: dict[str, str] = {}` Add methods: ```python async def get_current_session(self, user_id: str) -> str | None: return self._current_session.get(user_id) async def set_current_session(self, user_id: str, name: str) -> None: self._current_session[user_id] = name ``` 2. Create adapter/matrix/handlers/context_commands.py: ```python from __future__ import annotations import os from datetime import UTC, datetime from typing import TYPE_CHECKING import httpx import structlog from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage if TYPE_CHECKING: from lambda_agent_api.agent_api import AgentApi from sdk.prototype_state import PrototypeStateStore from core.store import StateStore logger = structlog.get_logger(__name__) SAVE_PROMPT = ( "Summarize our conversation and save to /workspace/contexts/{name}.md. " "Reply only with: Saved: {name}" ) LOAD_PROMPT = ( "Load context from /workspace/contexts/{name}.md and use it as background " "for our conversation. Reply: Loaded: {name}" ) def make_handle_save(agent_api: "AgentApi", store: "StateStore", prototype_state: "PrototypeStateStore"): async def handle_save( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: if event.args: name = event.args[0] else: name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" prompt = SAVE_PROMPT.format(name=name) try: await platform.send_message(event.user_id, event.chat_id, prompt) except Exception as exc: logger.warning("save_agent_call_failed", error=str(exc)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] await prototype_state.add_saved_session(event.user_id, name) return [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")] return handle_save def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"): async def handle_load( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: from adapter.matrix.store import set_load_pending sessions = await prototype_state.list_saved_sessions(event.user_id) if not sessions: return [OutgoingMessage( chat_id=event.chat_id, text="Нет сохранённых сессий. Используй !save [имя].", )] lines = ["Сохранённые сессии:"] for i, s in enumerate(sessions, start=1): created = s.get("created_at", "")[:10] lines.append(f" {i}. {s['name']} ({created})") lines.append("\nВведи номер или 0 / !cancel для отмены.") display = "\n".join(lines) await set_load_pending(store, event.user_id, event.chat_id, {"saves": sessions}) return [OutgoingMessage(chat_id=event.chat_id, text=display)] return handle_load def make_handle_reset(store: "StateStore", agent_base_url: str): async def handle_reset( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: from adapter.matrix.store import set_reset_pending await set_reset_pending(store, event.user_id, event.chat_id, {"active": True}) text = ( "Сбросить контекст агента? Выбери:\n" " !yes — сбросить\n" " !save [имя] — сохранить и сбросить\n" " !no — отмена" ) return [OutgoingMessage(chat_id=event.chat_id, text=text)] return handle_reset async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]: try: async with httpx.AsyncClient() as http: resp = await http.post(f"{agent_base_url}/reset", timeout=5.0) if resp.status_code == 404: return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")] return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")] except (httpx.ConnectError, httpx.TimeoutException) as exc: logger.warning("reset_endpoint_unreachable", error=str(exc)) return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")] def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"): async def handle_context( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr ) -> list[OutgoingEvent]: session_name = await prototype_state.get_current_session(event.user_id) or "не загружена" tokens = await prototype_state.get_last_tokens_used(event.user_id) sessions = await prototype_state.list_saved_sessions(event.user_id) lines = [ "Контекст:", f" Сессия: {session_name}", f" Токены (последний ответ): {tokens}", f" Сохранения ({len(sessions)}):", ] for s in sessions: created = s.get("created_at", "")[:10] lines.append(f" • {s['name']} ({created})") if not sessions: lines.append(" (нет)") return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] return handle_context ``` 3. Edit adapter/matrix/handlers/__init__.py: - Add import at top: `from adapter.matrix.handlers.context_commands import make_handle_save, make_handle_load, make_handle_reset, make_handle_context` - Change signature: `def register_matrix_handlers(dispatcher, client=None, store=None, agent_api=None, prototype_state=None, agent_base_url="http://127.0.0.1:8000") -> None:` - Add at bottom of function before the last line: ```python 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, "load", make_handle_load(store, prototype_state)) dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, agent_base_url)) dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state)) ``` 4. Edit adapter/matrix/bot.py: a. Add imports: `from adapter.matrix.store import get_load_pending, clear_load_pending, get_reset_pending, clear_reset_pending` b. In build_event_dispatcher() and build_runtime(), extract prototype_state from platform if RealPlatformClient, otherwise create new one: In build_runtime() after creating platform: ```python prototype_state = getattr(platform, "_prototype_state", None) agent_api = getattr(platform, "_agent_api", None) agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000") ``` Pass these to register_matrix_handlers: ```python register_matrix_handlers(dispatcher, client=client, store=store, agent_api=agent_api, prototype_state=prototype_state, agent_base_url=agent_base_url) ``` c. In MatrixBot.on_room_message(), before `incoming = from_room_event(...)`: ```python sender = getattr(event, "sender", None) # !load numeric interception load_pending = await get_load_pending(self.runtime.store, sender, room.room_id) if load_pending is not None: text = getattr(event, "body", "").strip() if text.isdigit() or text == "0" or text == "!cancel": outgoing = await self._handle_load_selection( sender, room.room_id, text, load_pending ) await self._send_all(room.room_id, outgoing) return ``` d. Add _handle_load_selection method to MatrixBot: ```python async def _handle_load_selection( self, user_id: str, room_id: str, text: str, pending: dict ) -> list[OutgoingEvent]: from adapter.matrix.store import clear_load_pending saves = pending.get("saves", []) if text == "0" or text == "!cancel": await clear_load_pending(self.runtime.store, user_id, room_id) return [OutgoingMessage(chat_id=room_id, text="Отменено.")] idx = int(text) - 1 if idx < 0 or idx >= len(saves): return [OutgoingMessage(chat_id=room_id, text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.")] name = saves[idx]["name"] await clear_load_pending(self.runtime.store, user_id, room_id) prototype_state = getattr(self.runtime.platform, "_prototype_state", None) if prototype_state is not None: await prototype_state.set_current_session(user_id, name) prompt = f"Load context from /workspace/contexts/{name}.md and use it as background for our conversation. Reply: Loaded: {name}" try: await self.runtime.platform.send_message(user_id, room_id, prompt) except Exception as exc: logger.warning("load_agent_call_failed", error=str(exc)) return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")] return [OutgoingMessage(chat_id=room_id, text=f"Загрузка: {name}")] ``` e. In MatrixBot.on_room_message(), also add reset_pending check for !yes/!no/!save commands: In the block after load_pending check, before calling dispatcher.dispatch: ```python # !reset pending interception for !yes, !no, !save commands reset_pending = await get_reset_pending(self.runtime.store, sender, room.room_id) if reset_pending is not None: body = getattr(event, "body", "").strip() if body == "!yes" or body.startswith("!save ") or body == "!no": outgoing = await self._handle_reset_selection(sender, room.room_id, body) await self._send_all(room.room_id, outgoing) return ``` f. Add _handle_reset_selection method to MatrixBot: ```python async def _handle_reset_selection( self, user_id: str, room_id: str, text: str ) -> list[OutgoingEvent]: from adapter.matrix.store import clear_reset_pending from adapter.matrix.handlers.context_commands import _call_reset_endpoint agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000") await clear_reset_pending(self.runtime.store, user_id, room_id) if text == "!no": return [OutgoingMessage(chat_id=room_id, text="Отменено.")] if text.startswith("!save "): name = text[len("!save "):].strip() prototype_state = getattr(self.runtime.platform, "_prototype_state", None) prompt = f"Summarize our conversation and save to /workspace/contexts/{name}.md. Reply only with: Saved: {name}" try: await self.runtime.platform.send_message(user_id, room_id, prompt) if prototype_state: await prototype_state.add_saved_session(user_id, name) except Exception as exc: logger.warning("save_before_reset_failed", error=str(exc)) return await _call_reset_endpoint(agent_base_url, room_id) ``` 5. Edit sdk/real.py — in stream_message(), after the final yield, add: ```python await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) ``` (This must come after `yield MessageChunk(finished=True, ...)` — use a local variable to store tokens_used before yield, then call set_last_tokens_used after the generator resumes.) Actually: put it before the final yield: ```python await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) yield MessageChunk( message_id=user_id, delta="", finished=True, tokens_used=self._agent_api.last_tokens_used, ) ``` 6. Create tests/adapter/matrix/test_context_commands.py: ```python from __future__ import annotations from typing import AsyncIterator from unittest.mock import AsyncMock, patch import pytest from adapter.matrix.bot import MatrixBot, build_runtime from core.protocol import IncomingCommand, OutgoingMessage from sdk.mock import MockPlatformClient from sdk.prototype_state import PrototypeStateStore def make_runtime_with_prototype_state(): proto = PrototypeStateStore() platform = MockPlatformClient() # Inject prototype_state into platform so handlers can find it platform._prototype_state = proto runtime = build_runtime(platform=platform) return runtime, proto @pytest.mark.asyncio async def test_save_command_auto_name_records_session(): proto = PrototypeStateStore() platform = MockPlatformClient() platform._prototype_state = proto from adapter.matrix.handlers.context_commands import make_handle_save from core.store import InMemoryStore store = InMemoryStore() handler = make_handle_save(agent_api=None, store=store, prototype_state=proto) event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!room:example", command="save", args=[]) class FakePlatform: async def send_message(self, *a, **kw): pass result = await handler(event, None, FakePlatform(), None, None) assert any(isinstance(r, OutgoingMessage) and "Сохранение запущено" in r.text for r in result) sessions = await proto.list_saved_sessions("u1") assert len(sessions) == 1 assert sessions[0]["name"].startswith("context-") @pytest.mark.asyncio async def test_save_command_with_name_uses_given_name(): proto = PrototypeStateStore() from adapter.matrix.handlers.context_commands import make_handle_save from core.store import InMemoryStore store = InMemoryStore() handler = make_handle_save(agent_api=None, store=store, prototype_state=proto) event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="save", args=["my-session"]) class FakePlatform: async def send_message(self, *a, **kw): pass await handler(event, None, FakePlatform(), None, None) sessions = await proto.list_saved_sessions("u1") assert sessions[0]["name"] == "my-session" @pytest.mark.asyncio async def test_load_command_shows_numbered_list(): proto = PrototypeStateStore() await proto.add_saved_session("u1", "session-A") await proto.add_saved_session("u1", "session-B") from adapter.matrix.handlers.context_commands import make_handle_load from core.store import InMemoryStore store = InMemoryStore() handler = make_handle_load(store=store, prototype_state=proto) event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[]) result = await handler(event, None, None, None, None) assert len(result) == 1 text = result[0].text assert "1." in text and "session-A" in text assert "2." in text and "session-B" in text assert "0" in text @pytest.mark.asyncio async def test_load_command_empty_sessions(): proto = PrototypeStateStore() from adapter.matrix.handlers.context_commands import make_handle_load from core.store import InMemoryStore store = InMemoryStore() handler = make_handle_load(store=store, prototype_state=proto) event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[]) result = await handler(event, None, None, None, None) assert "Нет сохранённых сессий" in result[0].text @pytest.mark.asyncio async def test_reset_command_shows_dialog(): proto = PrototypeStateStore() from adapter.matrix.handlers.context_commands import make_handle_reset from core.store import InMemoryStore store = InMemoryStore() handler = make_handle_reset(store=store, agent_base_url="http://127.0.0.1:8000") event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="reset", args=[]) result = await handler(event, None, None, None, None) text = result[0].text assert "!yes" in text assert "!save" in text assert "!no" in text @pytest.mark.asyncio async def test_reset_yes_reports_unavailable_when_endpoint_missing(): from adapter.matrix.handlers.context_commands import _call_reset_endpoint with patch("httpx.AsyncClient") as MockClient: import httpx MockClient.return_value.__aenter__ = AsyncMock(return_value=MockClient.return_value) MockClient.return_value.__aexit__ = AsyncMock(return_value=False) MockClient.return_value.post = AsyncMock(side_effect=httpx.ConnectError("refused")) result = await _call_reset_endpoint("http://127.0.0.1:8000", "!r:e") assert "недоступен" in result[0].text @pytest.mark.asyncio async def test_context_command_shows_snapshot(): proto = PrototypeStateStore() await proto.set_last_tokens_used("u1", 99) await proto.add_saved_session("u1", "my-save") from adapter.matrix.handlers.context_commands import make_handle_context from core.store import InMemoryStore store = InMemoryStore() handler = make_handle_context(store=store, prototype_state=proto) event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="context", args=[]) result = await handler(event, None, None, None, None) text = result[0].text assert "99" in text assert "my-save" in text assert "не загружена" in text ``` cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -v 2>&1 | tail -20 - adapter/matrix/handlers/context_commands.py exists with make_handle_save, make_handle_load, make_handle_reset, make_handle_context, _call_reset_endpoint - register_matrix_handlers() signature accepts agent_api, prototype_state, agent_base_url; registers save/load/reset/context handlers when agent_api is not None - MatrixBot.on_room_message() checks load_pending and reset_pending before dispatcher.dispatch - sdk/real.py calls set_last_tokens_used before final yield - All tests in test_context_commands.py pass - Full test suite still passes: `pytest tests/ -v` exits 0 ## Trust Boundaries | Boundary | Description | |----------|-------------| | Matrix user → command args | !save [name] arg is user-controlled; used in file paths | | bot → agent (save/load prompts) | Prompt text contains user-supplied name | | bot → POST /reset endpoint | HTTP call to AGENT_BASE_URL (internal service) | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-04-02-01 | Tampering | !save name arg used in /workspace/contexts/{name}.md path | mitigate | Sanitize name: only allow [a-zA-Z0-9_-] characters; reject path traversal attempts (names containing "/" or "..") | | T-04-02-02 | Information Disclosure | !context exposes tokens_used and session names | accept | Single-user prototype; data is the user's own | | T-04-02-03 | Denial of Service | !load numeric interception could loop if load_pending never clears | mitigate | clear_load_pending on selection (any) or disconnect; pending data is volatile in-memory | | T-04-02-04 | Spoofing | !yes intercepted by reset_pending could conflict with pending_confirm | mitigate | Reset_pending check in on_room_message before dispatcher — takes priority; documented in code comment | | T-04-02-05 | Tampering | POST /reset to AGENT_BASE_URL | accept | Internal service URL from env; timeout=5.0 prevents hanging | Run full suite after both tasks: ```bash cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30 ``` Grep checks: ```bash # Handlers registered grep "save\|load\|reset\|context" adapter/matrix/handlers/__init__.py # Numeric interception in bot grep "get_load_pending\|_handle_load_selection" adapter/matrix/bot.py # tokens tracking in real.py grep "set_last_tokens_used" sdk/real.py # context_commands module ls adapter/matrix/handlers/context_commands.py ``` - `pytest tests/adapter/matrix/test_context_commands.py -v` exits 0 with 7+ tests passing - `pytest tests/platform/test_prototype_state.py -v` exits 0 (including 4 new tests) - `pytest tests/ -v` exits 0 - !save, !load, !reset, !context all registered in register_matrix_handlers - load_pending and reset_pending helpers exist in adapter/matrix/store.py - MatrixBot.on_room_message contains numeric interception for !load After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md`