# Matrix Staged Attachments 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-side staged attachments so file-only events are stored per `(chat_id, user_id)`, acknowledged in chat, and committed to the agent on the next normal user message.

**Architecture:** Keep the existing shared-workspace file contract unchanged and add a thin Matrix-specific staging layer above it. The Matrix adapter will store staged attachment metadata in `adapter.matrix.store`, parse short staging commands in `adapter.matrix.converter`, and orchestrate commit/remove/list behavior in `adapter.matrix.bot` before calling the existing dispatcher.

**Tech Stack:** Python 3.11, matrix-nio, pytest, pytest-asyncio, in-memory state store, shared `/workspace`

---

## File Structure

- Modify: `adapter/matrix/store.py`
  Purpose: store staged attachment state per `(room_id, user_id)`.
- Modify: `adapter/matrix/converter.py`
  Purpose: parse `!list`, `!remove <n>`, `!remove all` into explicit Matrix-side commands.
- Modify: `adapter/matrix/bot.py`
  Purpose: stage file-only events, emit service acknowledgments, process staging commands, and commit staged files with the next normal message.
- Modify: `tests/adapter/matrix/test_store.py`
  Purpose: verify staged attachment persistence, ordering, and clear/remove helpers.
- Modify: `tests/adapter/matrix/test_converter.py`
  Purpose: verify short staging commands parse correctly.
- Modify: `tests/adapter/matrix/test_dispatcher.py`
  Purpose: verify Matrix bot staging, list/remove behavior, and commit semantics.
- Modify: `README.md`
  Purpose: document the Matrix staging UX and short commands.

### Task 1: Add Per-Chat Staged Attachment Storage

**Files:**
- Modify: `adapter/matrix/store.py`
- Test: `tests/adapter/matrix/test_store.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/adapter/matrix/test_store.py
from adapter.matrix.store import (
    add_staged_attachment,
    clear_staged_attachments,
    get_staged_attachments,
    remove_staged_attachment_at,
)


async def test_staged_attachments_roundtrip(store: InMemoryStore):
    await add_staged_attachment(
        store,
        room_id="!r1:example.org",
        user_id="@alice:example.org",
        attachment={
            "filename": "report.pdf",
            "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
            "mime_type": "application/pdf",
        },
    )

    assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == [
        {
            "filename": "report.pdf",
            "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
            "mime_type": "application/pdf",
        }
    ]


async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
    await add_staged_attachment(
        store,
        room_id="!r1:example.org",
        user_id="@alice:example.org",
        attachment={"filename": "a.pdf", "workspace_path": "a.pdf"},
    )
    await add_staged_attachment(
        store,
        room_id="!r2:example.org",
        user_id="@alice:example.org",
        attachment={"filename": "b.pdf", "workspace_path": "b.pdf"},
    )
    await add_staged_attachment(
        store,
        room_id="!r1:example.org",
        user_id="@bob:example.org",
        attachment={"filename": "c.pdf", "workspace_path": "c.pdf"},
    )

    assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
    assert [item["filename"] for item in await get_staged_attachments(store, "!r2:example.org", "@alice:example.org")] == ["b.pdf"]
    assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@bob:example.org")] == ["c.pdf"]


async def test_remove_staged_attachment_by_index(store: InMemoryStore):
    await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
    await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})

    removed = await remove_staged_attachment_at(store, "!r1:example.org", "@alice:example.org", 1)

    assert removed["filename"] == "b.pdf"
    assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]


async def test_clear_staged_attachments(store: InMemoryStore):
    await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})

    await clear_staged_attachments(store, "!r1:example.org", "@alice:example.org")

    assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == []
```
- [ ] **Step 2: Run tests to verify they fail**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`

Expected:
- FAIL because staged attachment helper functions do not exist yet

- [ ] **Step 3: Write minimal implementation**

```python
# adapter/matrix/store.py
STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"


def _staged_attachments_key(room_id: str, user_id: str) -> str:
    return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}"


async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
    return list(await store.get(_staged_attachments_key(room_id, user_id)) or [])


async def add_staged_attachment(
    store: StateStore,
    room_id: str,
    user_id: str,
    attachment: dict,
) -> None:
    items = await get_staged_attachments(store, room_id, user_id)
    items.append(attachment)
    await store.set(_staged_attachments_key(room_id, user_id), items)


async def remove_staged_attachment_at(
    store: StateStore,
    room_id: str,
    user_id: str,
    index: int,
) -> dict | None:
    items = await get_staged_attachments(store, room_id, user_id)
    if index < 0 or index >= len(items):
        return None
    removed = items.pop(index)
    await store.set(_staged_attachments_key(room_id, user_id), items)
    return removed


async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
    await store.delete(_staged_attachments_key(room_id, user_id))
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`

Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
git commit -m "feat: add matrix staged attachment state"
```

### Task 2: Parse Short Staging Commands

**Files:**
- Modify: `adapter/matrix/converter.py`
- Test: `tests/adapter/matrix/test_converter.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/adapter/matrix/test_converter.py
async def test_list_command_maps_to_matrix_staging_command():
    result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
    assert isinstance(result, IncomingCommand)
    assert result.command == "matrix_list_attachments"
    assert result.args == []


async def test_remove_all_maps_to_matrix_staging_command():
    result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
    assert isinstance(result, IncomingCommand)
    assert result.command == "matrix_remove_attachment"
    assert result.args == ["all"]


async def test_remove_index_maps_to_matrix_staging_command():
    result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
    assert isinstance(result, IncomingCommand)
    assert result.command == "matrix_remove_attachment"
    assert result.args == ["2"]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`

Expected:
- FAIL because `!list` and `!remove` still parse as generic unknown commands

- [ ] **Step 3: Write minimal implementation**

```python
# adapter/matrix/converter.py
def from_command(body: str, sender: str, chat_id: str, room_id: str | None = None) -> IncomingEvent:
    raw = body.lstrip("!").strip()
    parts = raw.split()
    command = parts[0].lower() if parts else ""
    args = parts[1:]

    if command == "list":
        return IncomingCommand(
            user_id=sender,
            platform=PLATFORM,
            chat_id=chat_id,
            command="matrix_list_attachments",
            args=[],
        )

    if command == "remove":
        return IncomingCommand(
            user_id=sender,
            platform=PLATFORM,
            chat_id=chat_id,
            command="matrix_remove_attachment",
            args=args,
        )
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`

Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
git commit -m "feat: parse matrix staged attachment commands"
```

### Task 3: Stage File-Only Events and Handle List/Remove UX

**Files:**
- Modify: `adapter/matrix/bot.py`
- Modify: `adapter/matrix/store.py`
- Test: `tests/adapter/matrix/test_dispatcher.py`

- [ ] **Step 1: Write the failing tests**

```python
# tests/adapter/matrix/test_dispatcher.py
async def test_file_only_event_is_staged_and_does_not_dispatch():
    runtime = build_runtime(platform=MockPlatformClient())
    client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
    bot = MatrixBot(client, runtime)
    runtime.dispatcher.dispatch = AsyncMock(return_value=[])
    bot._materialize_incoming_attachments = AsyncMock(
        return_value=IncomingMessage(
            user_id="@alice:example.org",
            platform="matrix",
            chat_id="matrix:!r:example.org",
            text="",
            attachments=[
                Attachment(
                    type="document",
                    filename="report.pdf",
                    workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
                    mime_type="application/pdf",
                )
            ],
        )
    )
    room = SimpleNamespace(room_id="!r:example.org")
    event = SimpleNamespace(
        sender="@alice:example.org",
        body="report.pdf",
        msgtype="m.file",
        url="mxc://hs/id",
        mimetype="application/pdf",
        replyto_event_id=None,
    )

    await bot.on_room_message(room, event)

    runtime.dispatcher.dispatch.assert_not_awaited()
    staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
    assert [item["filename"] for item in staged] == ["report.pdf"]
    client.room_send.assert_awaited_once()
    assert "Следующее сообщение" in client.room_send.await_args.args[2]["body"]


async def test_list_command_returns_current_staged_attachments():
    runtime = build_runtime(platform=MockPlatformClient())
    await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
    await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})
    client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
    bot = MatrixBot(client, runtime)
    room = SimpleNamespace(room_id="!r:example.org")
    event = SimpleNamespace(sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None)

    await bot.on_room_message(room, event)

    body = client.room_send.await_args.args[2]["body"]
    assert "1. a.pdf" in body
    assert "2. b.pdf" in body


async def test_remove_invalid_index_returns_short_error():
    runtime = build_runtime(platform=MockPlatformClient())
    await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
    client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
    bot = MatrixBot(client, runtime)
    room = SimpleNamespace(room_id="!r:example.org")
    event = SimpleNamespace(sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None)

    await bot.on_room_message(room, event)

    assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`

Expected:
- FAIL because file-only events still go straight to dispatcher
- FAIL because `!list` and `!remove` have no Matrix-side handling in the bot

- [ ] **Step 3: Write minimal implementation**

```python
# adapter/matrix/bot.py
def _is_staging_command(self, incoming: IncomingEvent) -> bool:
    return isinstance(incoming, IncomingCommand) and incoming.command in {
        "matrix_list_attachments",
        "matrix_remove_attachment",
    }


async def _handle_staging_command(self, room_id: str, user_id: str, incoming: IncomingCommand) -> list[OutgoingMessage]:
    if incoming.command == "matrix_list_attachments":
        return [OutgoingMessage(chat_id=incoming.chat_id, text=self._format_staged_attachments(room_id, user_id))]
    if incoming.command == "matrix_remove_attachment" and incoming.args == ["all"]:
        await clear_staged_attachments(self.runtime.store, room_id, user_id)
        return [OutgoingMessage(chat_id=incoming.chat_id, text="Вложения очищены.")]
```

```python
# adapter/matrix/bot.py
if isinstance(incoming, IncomingMessage) and incoming.attachments and not incoming.text:
    incoming = await self._materialize_incoming_attachments(room.room_id, sender, incoming)
    await self._stage_attachments(room.room_id, sender, incoming.attachments)
    await self._send_all(room.room_id, [OutgoingMessage(chat_id=dispatch_chat_id, text=self._format_staged_attachments(room.room_id, sender, with_hint=True))])
    return

if self._is_staging_command(incoming):
    outgoing = await self._handle_staging_command(room.room_id, sender, incoming)
    await self._send_all(room.room_id, outgoing)
    return
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`

Expected: PASS for staging/list/remove behavior

- [ ] **Step 5: Commit**

```bash
git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
git commit -m "feat: add matrix staging list and remove flow"
```

### Task 4: Commit Staged Files With the Next Normal Message

**Files:**
- Modify: `adapter/matrix/bot.py`
- Test: `tests/adapter/matrix/test_dispatcher.py`
- Modify: `README.md`

- [ ] **Step 1: Write the failing tests**

```python
# tests/adapter/matrix/test_dispatcher.py
async def test_next_normal_message_commits_staged_attachments():
    runtime = build_runtime(platform=MockPlatformClient())
    await add_staged_attachment(
        runtime.store,
        "!r:example.org",
        "@alice:example.org",
        {
            "filename": "report.pdf",
            "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
            "mime_type": "application/pdf",
        },
    )
    client = SimpleNamespace(user_id="@bot:example.org")
    bot = MatrixBot(client, runtime)
    bot._send_all = AsyncMock()
    runtime.dispatcher.dispatch = AsyncMock(return_value=[])
    room = SimpleNamespace(room_id="!r:example.org")
    event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None)

    await bot.on_room_message(room, event)

    dispatched = runtime.dispatcher.dispatch.await_args.args[0]
    assert isinstance(dispatched, IncomingMessage)
    assert dispatched.text == "Проанализируй"
    assert [a.workspace_path for a in dispatched.attachments] == [
        "surfaces/matrix/alice/r/inbox/report.pdf"
    ]
    assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []


async def test_failed_commit_preserves_staged_attachments():
    runtime = build_runtime(platform=MockPlatformClient())
    await add_staged_attachment(
        runtime.store,
        "!r:example.org",
        "@alice:example.org",
        {"filename": "report.pdf", "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf"},
    )
    client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
    bot = MatrixBot(client, runtime)
    runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
    room = SimpleNamespace(room_id="!r:example.org")
    event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None)

    await bot.on_room_message(room, event)

    staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
    assert [item["filename"] for item in staged] == ["report.pdf"]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`

Expected:
- FAIL because normal text messages do not yet merge staged attachments
- FAIL because staged items are never preserved/cleared based on commit outcome

- [ ] **Step 3: Write minimal implementation**

```python
# adapter/matrix/bot.py
async def _merge_staged_attachments(
    self,
    room_id: str,
    user_id: str,
    incoming: IncomingMessage,
) -> IncomingMessage:
    staged = await get_staged_attachments(self.runtime.store, room_id, user_id)
    if not staged:
        return incoming
    return IncomingMessage(
        user_id=incoming.user_id,
        platform=incoming.platform,
        chat_id=incoming.chat_id,
        text=incoming.text,
        reply_to=incoming.reply_to,
        attachments=[
            Attachment(
                type="document",
                filename=item.get("filename"),
                mime_type=item.get("mime_type"),
                workspace_path=item.get("workspace_path"),
            )
            for item in staged
        ],
    )
```

```python
# adapter/matrix/bot.py
staged_before_dispatch = False
if isinstance(incoming, IncomingMessage) and incoming.text and not incoming.attachments:
    staged = await get_staged_attachments(self.runtime.store, room.room_id, sender)
    if staged:
        incoming = await self._merge_staged_attachments(room.room_id, sender, incoming)
        staged_before_dispatch = True

try:
    outgoing = await self.runtime.dispatcher.dispatch(incoming)
except PlatformError:
    ...
else:
    if staged_before_dispatch:
        await clear_staged_attachments(self.runtime.store, room.room_id, sender)
```

- [ ] **Step 4: Run targeted tests to verify they pass**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -q`

Expected: PASS

- [ ] **Step 5: Update docs**

Add to `README.md`:

```md
### Matrix staged attachments

If a Matrix user sends files without a text instruction, the bot stages them per chat and replies with the current list.

- `!list` shows staged files
- `!remove <n>` removes one staged file by index
- `!remove all` clears all staged files

The next normal user message is sent to the agent together with all staged files.
```

- [ ] **Step 6: Run broader verification**

Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_send_outgoing.py tests/core/test_integration.py tests/platform/test_real.py -q`

Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py
git commit -m "feat: commit staged matrix attachments on next message"
```

## Self-Review

- Spec coverage:
  - staged per `(chat_id, user_id)`: Task 1
  - short commands `!list`, `!remove <n>`, `!remove all`: Task 2 and Task 3
  - file-only events do not invoke agent: Task 3
  - next normal message commits staged attachments: Task 4
  - failed commit preserves staged attachments: Task 4
  - docs update: Task 4
- Placeholder scan:
  - no `TODO`, `TBD`, or deferred behavior left in task steps
- Type consistency:
  - staged storage uses `dict` records with `filename`, `workspace_path`, `mime_type`
  - bot reconstructs `core.protocol.Attachment` from those same keys
