# Matrix Shared Workspace File Flow Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Move the Matrix surface to a prod-like shared-workspace file flow where incoming files are downloaded into `/workspace`, passed to `platform-agent` as relative attachment paths, and outbound `send_file` events are delivered back to the Matrix room. **Architecture:** Keep the platform contract path-based and surface-agnostic. Extend the surface attachment model with a workspace-relative path, update the real SDK bridge to forward attachments and modern agent events, then add a Matrix-specific storage helper plus a shared-volume runtime. This preserves room/context semantics while aligning file flow with upstream `platform-agent`. **Tech Stack:** Python 3.11, matrix-nio, aiohttp, Docker Compose, pytest, pytest-asyncio --- ## File Structure - Modify: `core/protocol.py` Purpose: add a workspace-relative attachment field that future surfaces can also use. - Modify: `sdk/interface.py` Purpose: keep the platform-side attachment shape aligned with the surface model. - Modify: `core/handlers/message.py` Purpose: stop dropping attachments before platform dispatch. - Modify: `sdk/agent_api_wrapper.py` Purpose: accept modern upstream agent events and modern WS route semantics. - Modify: `sdk/real.py` Purpose: convert attachment objects into workspace-relative paths and forward them to the agent API. - Create: `adapter/matrix/files.py` Purpose: Matrix-specific download/upload helper for shared `/workspace`. - Modify: `adapter/matrix/bot.py` Purpose: persist incoming Matrix files into shared workspace and render outbound file events back to Matrix. - Modify: `tests/core/test_integration.py` Purpose: prove message dispatch keeps attachments and platform send path receives them. - Modify: `tests/platform/test_real.py` Purpose: verify attachment forwarding and outbound file events. - Create: `tests/adapter/matrix/test_files.py` Purpose: unit coverage for Matrix workspace path building, download handling, and outbound upload behavior. - Modify: `tests/adapter/matrix/test_dispatcher.py` Purpose: verify Matrix bot file receive/send integration. - Modify: `docker-compose.yml` Purpose: define shared `/workspace` runtime between `matrix-bot` and `platform-agent`. - Modify: `README.md` Purpose: document the new default runtime and file flow. - Modify: `.env.example` Purpose: update real-backend defaults to in-compose service URLs and workspace-aware runtime. ### Task 1: Preserve Attachment Metadata Through Core Message Dispatch **Files:** - Modify: `core/protocol.py` - Modify: `sdk/interface.py` - Modify: `core/handlers/message.py` - Test: `tests/core/test_dispatcher.py` - Test: `tests/core/test_integration.py` - [ ] **Step 1: Write the failing tests** ```python # tests/core/test_integration.py class RecordingAgentApi: def __init__(self) -> None: self.calls: list[tuple[str, list[str]]] = [] self.last_tokens_used = 0 async def send_message(self, text: str, attachments: list[str] | None = None): self.calls.append((text, attachments or [])) yield type("Chunk", (), {"text": f"[REAL] {text}"})() self.last_tokens_used = 5 async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher): dispatcher, agent_api = real_dispatcher start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") await dispatcher.dispatch(start) msg = IncomingMessage( user_id="u1", platform="matrix", chat_id="C1", text="Посмотри файл", attachments=[ Attachment( type="document", filename="report.pdf", mime_type="application/pdf", workspace_path="surfaces/matrix/u1/room/inbox/report.pdf", ) ], ) await dispatcher.dispatch(msg) assert agent_api.calls == [ ("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"]) ] ``` ```python # tests/core/test_dispatcher.py async def test_dispatch_routes_document_before_catchall(dispatcher): async def doc_handler(event, **kwargs): return [OutgoingMessage(chat_id=event.chat_id, text="document")] async def catch_all(event, **kwargs): return [OutgoingMessage(chat_id=event.chat_id, text="text")] dispatcher.register(IncomingMessage, "document", doc_handler) dispatcher.register(IncomingMessage, "*", catch_all) doc_msg = IncomingMessage( user_id="u1", platform="matrix", chat_id="C1", text="", attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.txt")], ) assert (await dispatcher.dispatch(doc_msg))[0].text == "document" ``` - [ ] **Step 2: Run tests to verify they fail** Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q` Expected: - FAIL because `Attachment` has no `workspace_path` - FAIL because `handle_message(...)` still sends `attachments=[]` - [ ] **Step 3: Write minimal implementation** ```python # core/protocol.py @dataclass class Attachment: type: str url: str | None = None content: bytes | None = None filename: str | None = None mime_type: str | None = None workspace_path: str | None = None ``` ```python # sdk/interface.py class Attachment(BaseModel): url: str | None = None mime_type: str | None = None size: int | None = None filename: str | None = None workspace_path: str | None = None ``` ```python # core/handlers/message.py response = await platform.send_message( user_id=event.user_id, chat_id=event.chat_id, text=event.text, attachments=event.attachments, ) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add core/protocol.py sdk/interface.py core/handlers/message.py tests/core/test_dispatcher.py tests/core/test_integration.py git commit -m "feat: preserve workspace attachments through message dispatch" ``` ### Task 2: Upgrade the Real SDK Bridge to Modern Agent File Events **Files:** - Modify: `sdk/agent_api_wrapper.py` - Modify: `sdk/real.py` - Test: `tests/platform/test_real.py` - [ ] **Step 1: Write the failing tests** ```python # tests/platform/test_real.py class FakeSendFileEvent: def __init__(self, path: str) -> None: self.path = path class FakeChatAgentApi: ... async def send_message(self, text: str, attachments: list[str] | None = None): self.calls.append((text, attachments or [])) midpoint = len(text) // 2 yield FakeChunk(text[:midpoint]) yield FakeChunk(text[midpoint:]) self.last_tokens_used = 3 @pytest.mark.asyncio async def test_real_platform_client_send_message_forwards_workspace_paths(): agent_api = FakeAgentApiFactory() client = RealPlatformClient( agent_api=agent_api, prototype_state=PrototypeStateStore(), platform="matrix", ) await client.send_message( "@alice:example.org", "chat-7", "hello", attachments=[ type("Attachment", (), {"workspace_path": "surfaces/matrix/alice/room/file.pdf"})() ], ) assert agent_api.instances["chat-7"].calls == [ ("hello", ["surfaces/matrix/alice/room/file.pdf"]) ] def test_agent_api_wrapper_treats_send_file_as_known_event(monkeypatch): seen = [] class FakeSendFile: type = "AGENT_EVENT_SEND_FILE" path = "docs/result.pdf" monkeypatch.setattr( "sdk.agent_api_wrapper.ServerMessage.validate_json", lambda raw: FakeSendFile(), ) wrapper = AgentApiWrapper(agent_id="agent-1", base_url="https://agent.example.com", chat_id="7") wrapper.callback = seen.append wrapper._current_queue = None # use the wrapper's dispatch branch directly inside _listen test harness ``` - [ ] **Step 2: Run tests to verify they fail** Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q` Expected: - FAIL because `RealPlatformClient` ignores attachments - FAIL because `AgentApiWrapper` only recognizes text/end/error/disconnect events - [ ] **Step 3: Write minimal implementation** ```python # sdk/real.py def _attachment_paths(self, attachments) -> list[str]: if not attachments: return [] paths = [] for attachment in attachments: path = getattr(attachment, "workspace_path", None) if path: paths.append(path) return paths async def stream_message(...): attachment_paths = self._attachment_paths(attachments) ... async for event in chat_api.send_message(text, attachments=attachment_paths): if hasattr(event, "path"): yield MessageChunk( message_id=user_id, delta="", finished=False, ) continue yield MessageChunk(...) ``` ```python # sdk/agent_api_wrapper.py from lambda_agent_api.server import ( MsgError, MsgEventCustomUpdate, MsgEventEnd, MsgEventSendFile, MsgEventTextChunk, MsgEventToolCallChunk, MsgEventToolResult, MsgGracefulDisconnect, ServerMessage, ) KNOWN_STREAM_EVENTS = ( MsgEventTextChunk, MsgEventToolCallChunk, MsgEventToolResult, MsgEventCustomUpdate, MsgEventSendFile, MsgEventEnd, ) if isinstance(outgoing_msg, KNOWN_STREAM_EVENTS): if isinstance(outgoing_msg, MsgEventEnd): self.last_tokens_used = outgoing_msg.tokens_used if self._current_queue: await self._current_queue.put(outgoing_msg) elif self.callback: self.callback(outgoing_msg) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py git commit -m "feat: support attachment paths and file events in real sdk bridge" ``` ### Task 3: Implement Matrix Shared-Workspace Receive/Send File Flow **Files:** - Create: `adapter/matrix/files.py` - Modify: `adapter/matrix/bot.py` - Test: `tests/adapter/matrix/test_files.py` - Test: `tests/adapter/matrix/test_dispatcher.py` - [ ] **Step 1: Write the failing tests** ```python # tests/adapter/matrix/test_files.py from pathlib import Path import pytest from adapter.matrix.files import build_workspace_attachment_path def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path): rel_path, abs_path = build_workspace_attachment_path( workspace_root=tmp_path, matrix_user_id="@alice:example.org", room_id="!room:example.org", filename="report.pdf", timestamp="20260420-153000", ) assert rel_path == "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf" assert abs_path == tmp_path / rel_path ``` ```python # tests/adapter/matrix/test_dispatcher.py async def test_bot_downloads_matrix_file_to_workspace_before_dispatch(tmp_path): runtime = build_runtime(platform=MockPlatformClient()) await set_room_meta( runtime.store, "!chat1:example.org", { "chat_id": "C1", "matrix_user_id": "@alice:example.org", "platform_chat_id": "matrix:ctx-1", }, ) client = SimpleNamespace( user_id="@bot:example.org", download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")), ) bot = MatrixBot(client, runtime) bot._send_all = AsyncMock() runtime.dispatcher.dispatch = AsyncMock(return_value=[]) room = SimpleNamespace(room_id="!chat1:example.org") event = SimpleNamespace( sender="@alice:example.org", body="Посмотри", msgtype="m.file", url="mxc://server/id", mimetype="application/pdf", replyto_event_id=None, ) await bot.on_room_message(room, event) dispatched = runtime.dispatcher.dispatch.await_args.args[0] assert dispatched.attachments[0].workspace_path.endswith(".pdf") ``` ```python # tests/adapter/matrix/test_dispatcher.py async def test_send_outgoing_uploads_file_attachment_to_matrix(tmp_path): path = tmp_path / "result.txt" path.write_text("ready") client = SimpleNamespace( upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})), room_send=AsyncMock(), ) await send_outgoing( client, "!room:example.org", OutgoingMessage( chat_id="!room:example.org", text="Файл готов", attachments=[ Attachment( type="document", filename="result.txt", mime_type="text/plain", workspace_path=str(path), ) ], ), ) client.upload.assert_awaited() client.room_send.assert_awaited() ``` - [ ] **Step 2: Run tests to verify they fail** Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q` Expected: - FAIL because `adapter.matrix.files` does not exist - FAIL because Matrix bot does not persist files before dispatch - FAIL because `send_outgoing(...)` only sends text - [ ] **Step 3: Write minimal implementation** ```python # adapter/matrix/files.py from __future__ import annotations from pathlib import Path from datetime import UTC, datetime import re from core.protocol import Attachment def _sanitize_component(value: str) -> str: stripped = re.sub(r"[^A-Za-z0-9._-]+", "_", value) return stripped.strip("._-") or "unknown" def build_workspace_attachment_path( *, workspace_root: Path, matrix_user_id: str, room_id: str, filename: str, timestamp: str | None = None, ) -> tuple[str, Path]: stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S") safe_user = _sanitize_component(matrix_user_id.lstrip("@")) safe_room = _sanitize_component(room_id.lstrip("!")) safe_name = _sanitize_component(filename) or "attachment.bin" rel_path = Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}" return rel_path.as_posix(), workspace_root / rel_path ``` ```python # adapter/matrix/bot.py from adapter.matrix.files import download_matrix_attachments, load_workspace_attachment ... incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) if isinstance(incoming, IncomingMessage) and incoming.attachments: incoming = await self._materialize_attachments(room.room_id, sender, incoming) ... async def _materialize_attachments(...): workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) attachments = await download_matrix_attachments(...) return IncomingMessage(..., attachments=attachments, ...) ``` ```python # adapter/matrix/bot.py if isinstance(event, OutgoingMessage) and event.attachments: for attachment in event.attachments: if attachment.workspace_path: await _send_matrix_file(client, room_id, attachment) if event.text: await client.room_send(...) return ``` - [ ] **Step 4: Run tests to verify they pass** Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q` Expected: PASS - [ ] **Step 5: Commit** ```bash git add adapter/matrix/files.py adapter/matrix/bot.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py git commit -m "feat: add matrix shared-workspace file receive and send flow" ``` ### Task 4: Make Shared Workspace the Default Local Runtime **Files:** - Modify: `docker-compose.yml` - Modify: `README.md` - Modify: `.env.example` - [ ] **Step 1: Write the failing configuration checks** ```bash python - <<'PY' from pathlib import Path text = Path("docker-compose.yml").read_text() assert "platform-agent" in text assert "/workspace" in text assert "matrix-bot" in text PY ``` ```bash python - <<'PY' from pathlib import Path readme = Path("README.md").read_text() assert "docker compose up" in readme assert "/workspace" in readme assert "platform-agent" in readme PY ``` - [ ] **Step 2: Run checks to verify they fail** Run: `python - <<'PY' ... PY` Expected: - FAIL because root compose only defines `matrix-bot` - FAIL because README still documents standalone `uvicorn` launch and old WS route - [ ] **Step 3: Write minimal implementation** ```yaml # docker-compose.yml services: platform-agent: build: context: ./external/platform-agent target: development additional_contexts: agent_api: ./external/platform-agent_api env_file: - ./external/platform-agent/.env volumes: - workspace:/workspace - ./external/platform-agent/src:/app/src - ./external/platform-agent_api:/agent_api ports: - "8000:8000" matrix-bot: build: . env_file: .env depends_on: - platform-agent volumes: - workspace:/workspace restart: unless-stopped volumes: workspace: ``` ```env # .env.example AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/0/ AGENT_BASE_URL=http://platform-agent:8000 SURFACES_WORKSPACE_DIR=/workspace MATRIX_PLATFORM_BACKEND=real ``` ```md # README.md - make the root `docker compose up` path the primary local runtime - describe shared `/workspace` as the file contract - remove the statement that real backend is text-only and has no attachments - replace the old standalone `uvicorn` instructions with compose-first instructions ``` - [ ] **Step 4: Run checks to verify they pass** Run: `python - <<'PY' ... PY` Expected: PASS - [ ] **Step 5: Commit** ```bash git add docker-compose.yml README.md .env.example git commit -m "chore: make shared workspace runtime the default local setup" ``` ## Self-Review - Spec coverage: - shared `/workspace` runtime: Task 4 - incoming Matrix file persistence: Task 3 - attachment path propagation to agent API: Tasks 1-2 - outbound `send_file` flow: Tasks 2-3 - future-surface-friendly attachment contract: Task 1 - Placeholder scan: - no `TODO`, `TBD`, or “similar to” - each task has explicit test, run, implementation, verify, commit steps - Type consistency: - `workspace_path` is introduced in both attachment models and consumed consistently in Tasks 1-3 - path-based contract is always relative to `/workspace` until Matrix upload resolution step ## Execution Handoff User already selected parallel subagent execution. Use subagent-driven development and split ownership like this: - Worker A: `docker-compose.yml`, `README.md`, `.env.example` - Worker B: `sdk/agent_api_wrapper.py`, `sdk/real.py`, `tests/platform/test_real.py` - Main session / later worker: `core/protocol.py`, `sdk/interface.py`, `core/handlers/message.py`, `adapter/matrix/files.py`, `adapter/matrix/bot.py`, matrix/core tests