19 KiB
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.pyPurpose: add a workspace-relative attachment field that future surfaces can also use. - Modify:
sdk/interface.pyPurpose: keep the platform-side attachment shape aligned with the surface model. - Modify:
core/handlers/message.pyPurpose: stop dropping attachments before platform dispatch. - Modify:
sdk/agent_api_wrapper.pyPurpose: accept modern upstream agent events and modern WS route semantics. - Modify:
sdk/real.pyPurpose: convert attachment objects into workspace-relative paths and forward them to the agent API. - Create:
adapter/matrix/files.pyPurpose: Matrix-specific download/upload helper for shared/workspace. - Modify:
adapter/matrix/bot.pyPurpose: persist incoming Matrix files into shared workspace and render outbound file events back to Matrix. - Modify:
tests/core/test_integration.pyPurpose: prove message dispatch keeps attachments and platform send path receives them. - Modify:
tests/platform/test_real.pyPurpose: verify attachment forwarding and outbound file events. - Create:
tests/adapter/matrix/test_files.pyPurpose: unit coverage for Matrix workspace path building, download handling, and outbound upload behavior. - Modify:
tests/adapter/matrix/test_dispatcher.pyPurpose: verify Matrix bot file receive/send integration. - Modify:
docker-compose.ymlPurpose: define shared/workspaceruntime betweenmatrix-botandplatform-agent. - Modify:
README.mdPurpose: document the new default runtime and file flow. - Modify:
.env.examplePurpose: 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
# 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"])
]
# 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
Attachmenthas noworkspace_path -
FAIL because
handle_message(...)still sendsattachments=[] -
Step 3: Write minimal implementation
# 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
# 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
# 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
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
# 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
RealPlatformClientignores attachments -
FAIL because
AgentApiWrapperonly recognizes text/end/error/disconnect events -
Step 3: Write minimal implementation
# 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(...)
# 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
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
# 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
# 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")
# 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.filesdoes not exist -
FAIL because Matrix bot does not persist files before dispatch
-
FAIL because
send_outgoing(...)only sends text -
Step 3: Write minimal implementation
# 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
# 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, ...)
# 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
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
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
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
uvicornlaunch and old WS route -
Step 3: Write minimal implementation
# 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.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
# 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
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
/workspaceruntime: Task 4 - incoming Matrix file persistence: Task 3
- attachment path propagation to agent API: Tasks 1-2
- outbound
send_fileflow: Tasks 2-3 - future-surface-friendly attachment contract: Task 1
- shared
- Placeholder scan:
- no
TODO,TBD, or “similar to” - each task has explicit test, run, implementation, verify, commit steps
- no
- Type consistency:
workspace_pathis introduced in both attachment models and consumed consistently in Tasks 1-3- path-based contract is always relative to
/workspaceuntil 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