surfaces/docs/superpowers/plans/2026-03-29-core-implementation.md

54 KiB
Raw Blame History

Core 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: Реализовать core/ и platform/ — общее ядро для Telegram и Matrix ботов.

Architecture: EventDispatcher в core/handler.py маршрутизирует входящие события по типу + ключу к изолированным обработчикам в core/handlers/*.py. Три менеджера (ChatManager, AuthManager, SettingsManager) инкапсулируют бизнес-логику и разделяют один StateStore. PlatformClient — единственная точка взаимодействия с Lambda платформой (сейчас — mock).

Tech Stack: Python 3.11+, dataclasses, Pydantic v2, structlog, pytest + pytest-asyncio (asyncio_mode=auto), SQLite


Карта файлов

Файл Действие Ответственность
platform/interface.py Создать PlatformClient Protocol + Pydantic модели
platform/mock.py Создать (из src/) MockPlatformClient — новый контракт
platform/__init__.py Создать пустой
tests/platform/test_mock.py Создать (из tests/) тесты mock под новый API
core/__init__.py Создать пустой
core/protocol.py Создать все dataclasses (IncomingEvent, OutgoingEvent, ...)
core/store.py Создать StateStore Protocol + InMemoryStore + SQLiteStore
core/chat.py Создать ChatManager — метаданные чатов C1/C2/C3
core/auth.py Создать AuthManager — auth state machine
core/settings.py Создать SettingsManager — скиллы, коннекторы и т.д.
core/handler.py Создать EventDispatcher — только маршрутизация
core/handlers/__init__.py Создать register_all() — регистрация обработчиков
core/handlers/start.py Создать /start — auth + приветствие
core/handlers/message.py Создать текстовое сообщение + voice fallback
core/handlers/chat.py Создать /new, /rename, /archive, /chats
core/handlers/callback.py Создать confirm, cancel, toggle_skill
core/handlers/settings.py Создать /settings + подменю
tests/core/test_protocol.py Создать smoke-тесты dataclasses
tests/core/test_store.py Создать InMemoryStore + SQLiteStore
tests/core/test_chat.py Создать ChatManager
tests/core/test_auth.py Создать AuthManager
tests/core/test_dispatcher.py Создать EventDispatcher маршрутизация
tests/core/test_voice_slot.py Создать voice fallback behaviour
src/ Удалить после переноса в platform/

Task 1: Migrate src/ → platform/

Files:

  • Create: platform/__init__.py

  • Create: platform/interface.py

  • Create: platform/mock.py

  • Create: tests/platform/__init__.py

  • Create: tests/platform/test_mock.py

  • Delete: src/

  • Step 1: Создать platform/__init__.py и tests/platform/__init__.py

mkdir -p platform tests/platform tests/core
touch platform/__init__.py tests/platform/__init__.py tests/core/__init__.py
  • Step 2: Создать platform/interface.py
# platform/interface.py
from __future__ import annotations

from datetime import datetime
from typing import Any, Protocol

from pydantic import BaseModel


class User(BaseModel):
    user_id: str
    external_id: str
    platform: str
    display_name: str | None = None
    created_at: datetime
    is_new: bool = False


class MessageResponse(BaseModel):
    message_id: str
    response: str
    tokens_used: int
    finished: bool


class UserSettings(BaseModel):
    skills: dict[str, bool] = {}
    connectors: dict[str, dict] = {}
    soul: dict[str, str] = {}
    safety: dict[str, bool] = {}
    plan: dict[str, Any] = {}


class PlatformError(Exception):
    def __init__(self, message: str, code: str = "PLATFORM_ERROR"):
        super().__init__(message)
        self.code = code


class PlatformClient(Protocol):
    async def get_or_create_user(
        self,
        external_id: str,
        platform: str,
        display_name: str | None = None,
    ) -> User: ...

    # Master manages container lifecycle — bot only sends user_id + chat_id.
    async def send_message(
        self,
        user_id: str,
        chat_id: str,
        text: str,
        attachments: list | None = None,
    ) -> MessageResponse: ...

    async def get_settings(self, user_id: str) -> UserSettings: ...

    async def update_settings(self, user_id: str, action: Any) -> None: ...
  • Step 3: Создать platform/mock.py
# platform/mock.py
from __future__ import annotations

import asyncio
import random
import uuid
from datetime import datetime
from typing import Any

import structlog

from platform.interface import MessageResponse, PlatformError, User, UserSettings

logger = structlog.get_logger(__name__)


class MockPlatformClient:
    """
    Заглушка SDK платформы Lambda.

    Реализует PlatformClient Protocol. При подключении реального SDK
    заменяется только этот файл — core/ и адаптеры не трогаются.

    Ключевое отличие от реальной платформы: не управляет lifecycle контейнера.
    Master делает это сам при получении send_message.
    """

    def __init__(self) -> None:
        self._users: dict[str, dict] = {}
        self._messages: dict[str, list] = {}  # user_id:chat_id → messages
        self._settings: dict[str, dict] = {}
        logger.info("MockPlatformClient initialized")

    async def get_or_create_user(
        self,
        external_id: str,
        platform: str,
        display_name: str | None = None,
    ) -> User:
        await self._latency()
        key = f"{platform}:{external_id}"
        if key not in self._users:
            self._users[key] = {
                "user_id": f"usr-{platform}-{external_id}",
                "external_id": external_id,
                "platform": platform,
                "display_name": display_name,
                "created_at": "2025-01-01T00:00:00Z",
                "is_new": True,
            }
        data = self._users[key]
        return User(**data)

    async def send_message(
        self,
        user_id: str,
        chat_id: str,
        text: str,
        attachments: list | None = None,
    ) -> MessageResponse:
        await self._latency(200, 600)
        key = f"{user_id}:{chat_id}"
        if key not in self._messages:
            self._messages[key] = []

        message_id = str(uuid.uuid4())
        preview = text[:50] + ("..." if len(text) > 50 else "")
        response = f"[MOCK] Ответ на: «{preview}»"

        self._messages[key].append({
            "message_id": message_id,
            "user_text": text,
            "response": response,
            "tokens_used": len(text.split()) * 2,
            "finished": True,
            "created_at": datetime.utcnow().isoformat() + "Z",
        })

        logger.info("Message sent", user_id=user_id, chat_id=chat_id, message_id=message_id)
        return MessageResponse(
            message_id=message_id,
            response=response,
            tokens_used=len(text.split()) * 2,
            finished=True,
        )

    async def get_settings(self, user_id: str) -> UserSettings:
        await self._latency()
        stored = self._settings.get(user_id, {})
        return UserSettings(
            skills=stored.get("skills", {
                "web-search": True,
                "fetch-url": True,
                "email": False,
                "browser": False,
                "image-gen": False,
                "files": True,
            }),
            connectors=stored.get("connectors", {}),
            soul=stored.get("soul", {"name": "Лямбда", "style": "friendly"}),
            safety=stored.get("safety", {
                "email-send": True,
                "file-delete": True,
                "social-post": True,
            }),
            plan=stored.get("plan", {
                "name": "Beta",
                "tokens_used": 0,
                "tokens_limit": 1000,
            }),
        )

    async def update_settings(self, user_id: str, action: Any) -> None:
        await self._latency()
        settings = self._settings.setdefault(user_id, {})

        if action.action == "toggle_skill":
            skills = settings.setdefault("skills", {})
            skills[action.payload["skill"]] = action.payload.get("enabled", True)
        elif action.action == "set_soul":
            soul = settings.setdefault("soul", {})
            soul[action.payload["field"]] = action.payload["value"]
        elif action.action == "set_safety":
            safety = settings.setdefault("safety", {})
            safety[action.payload["trigger"]] = action.payload.get("enabled", True)

        logger.info("Settings updated", user_id=user_id, action=action.action)

    def get_stats(self) -> dict:
        return {
            "total_users": len(self._users),
            "total_messages": sum(len(msgs) for msgs in self._messages.values()),
        }

    async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
        await asyncio.sleep(random.randint(min_ms, max_ms) / 1000)
  • Step 4: Создать tests/platform/test_mock.py
# tests/platform/test_mock.py
import pytest
from platform.mock import MockPlatformClient
from platform.interface import User, MessageResponse, UserSettings
from core.protocol import SettingsAction


@pytest.fixture
def client():
    return MockPlatformClient()


async def test_get_or_create_user_returns_user(client):
    user = await client.get_or_create_user("12345", "telegram", "Иван")
    assert isinstance(user, User)
    assert user.external_id == "12345"
    assert user.platform == "telegram"
    assert user.is_new is True


async def test_get_or_create_user_idempotent(client):
    u1 = await client.get_or_create_user("42", "matrix")
    u2 = await client.get_or_create_user("42", "matrix")
    assert u1.user_id == u2.user_id


async def test_send_message_returns_response(client):
    user = await client.get_or_create_user("u1", "telegram")
    result = await client.send_message(user.user_id, "C1", "Привет!")
    assert isinstance(result, MessageResponse)
    assert result.finished is True
    assert len(result.response) > 0


async def test_get_settings_returns_defaults(client):
    settings = await client.get_settings("usr-telegram-42")
    assert isinstance(settings, UserSettings)
    assert "web-search" in settings.skills


async def test_update_settings_toggle_skill(client):
    uid = "usr-1"
    action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
    await client.update_settings(uid, action)
    settings = await client.get_settings(uid)
    assert settings.skills.get("browser") is True
  • Step 5: Запустить тесты — убедиться что проходят
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run pytest tests/platform/ -v

Ожидаем: 5 PASSED

  • Step 6: Удалить src/
rm -rf src/
  • Step 7: Убедиться что старые тесты не сломали ничего нового
uv run pytest tests/platform/ -v

Ожидаем: 5 PASSED (старый tests/test_mock_platform.py больше не нужен — удалить)

rm tests/test_mock_platform.py
  • Step 8: Commit
git add platform/ tests/platform/ tests/core/
git rm -r src/ tests/test_mock_platform.py
git commit -m "feat: migrate src/ to platform/ with new PlatformClient contract"

Task 2: core/protocol.py

Files:

  • Create: core/__init__.py

  • Create: core/protocol.py

  • Create: tests/core/test_protocol.py

  • Step 1: Написать тест

# tests/core/test_protocol.py
from datetime import datetime
from core.protocol import (
    Attachment, IncomingMessage, IncomingCommand, IncomingCallback,
    OutgoingMessage, OutgoingUI, OutgoingTyping, OutgoingNotification,
    UIButton, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction,
    PaymentRequired,
)


def test_incoming_message_defaults():
    msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")
    assert msg.attachments == []
    assert msg.reply_to is None


def test_attachment_audio():
    a = Attachment(type="audio", filename="voice.ogg", mime_type="audio/ogg")
    assert a.type == "audio"
    assert a.url is None


def test_incoming_command_defaults():
    cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new")
    assert cmd.args == []


def test_outgoing_message_defaults():
    msg = OutgoingMessage(chat_id="C1", text="hello")
    assert msg.parse_mode == "plain"
    assert msg.attachments == []


def test_ui_button_defaults():
    btn = UIButton(label="OK", action="confirm")
    assert btn.style == "secondary"
    assert btn.payload == {}


def test_settings_action():
    action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
    assert action.action == "toggle_skill"
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_protocol.py -v

Ожидаем: ImportError: No module named 'core'

  • Step 3: Создать core/__init__.py и core/protocol.py
mkdir -p core/handlers
touch core/__init__.py core/handlers/__init__.py
# core/protocol.py
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Attachment:
    type: str  # "image" | "document" | "audio" | "video"
    url: str | None = None
    content: bytes | None = None
    filename: str | None = None
    mime_type: str | None = None


@dataclass
class IncomingMessage:
    user_id: str
    platform: str
    chat_id: str
    text: str
    attachments: list[Attachment] = field(default_factory=list)
    reply_to: str | None = None


@dataclass
class IncomingCommand:
    user_id: str
    platform: str
    chat_id: str
    command: str
    args: list[str] = field(default_factory=list)


@dataclass
class IncomingCallback:
    user_id: str
    platform: str
    chat_id: str
    action: str
    payload: dict = field(default_factory=dict)


@dataclass
class OutgoingMessage:
    chat_id: str
    text: str
    parse_mode: str = "plain"
    attachments: list[Attachment] = field(default_factory=list)
    reply_to: str | None = None


@dataclass
class UIButton:
    label: str
    action: str
    payload: dict = field(default_factory=dict)
    style: str = "secondary"  # "primary" | "danger" | "secondary"


@dataclass
class OutgoingUI:
    chat_id: str
    text: str
    buttons: list[UIButton] = field(default_factory=list)


@dataclass
class OutgoingNotification:
    chat_id: str
    text: str
    level: str = "info"  # "info" | "warning" | "success" | "error"


@dataclass
class OutgoingTyping:
    chat_id: str
    is_typing: bool


@dataclass
class ChatContext:
    chat_id: str
    display_name: str
    platform: str
    surface_ref: str
    created_at: datetime
    is_archived: bool = False


@dataclass
class AuthFlow:
    user_id: str
    platform: str
    state: str  # "pending" | "code_sent" | "confirmed" | "failed"
    platform_user_id: str | None = None


@dataclass
class ConfirmationRequest:
    action_id: str
    chat_id: str
    description: str
    risk_level: str  # "low" | "medium" | "high"
    expires_at: datetime


@dataclass
class SettingsAction:
    action: str
    payload: dict = field(default_factory=dict)


@dataclass
class PaymentRequired:
    user_id: str
    reason: str
    current_plan: str


# Type aliases
IncomingEvent = IncomingMessage | IncomingCommand | IncomingCallback
OutgoingEvent = OutgoingMessage | OutgoingUI | OutgoingNotification | OutgoingTyping
  • Step 4: Запустить тесты
uv run pytest tests/core/test_protocol.py -v

Ожидаем: 6 PASSED

  • Step 5: Commit
git add core/ tests/core/test_protocol.py
git commit -m "feat: add core/protocol.py — IncomingEvent and OutgoingEvent dataclasses"

Task 3: core/store.py

Files:

  • Create: core/store.py

  • Create: tests/core/test_store.py

  • Step 1: Написать тест

# tests/core/test_store.py
import pytest
import tempfile
import os
from core.store import InMemoryStore, SQLiteStore


# ── InMemoryStore ──────────────────────────────────────────────────────────────

async def test_inmemory_get_missing_returns_none():
    store = InMemoryStore()
    assert await store.get("missing") is None


async def test_inmemory_set_and_get():
    store = InMemoryStore()
    await store.set("k", {"x": 1})
    assert await store.get("k") == {"x": 1}


async def test_inmemory_delete():
    store = InMemoryStore()
    await store.set("k", {"x": 1})
    await store.delete("k")
    assert await store.get("k") is None


async def test_inmemory_keys_prefix():
    store = InMemoryStore()
    await store.set("chat:u1:C1", {"a": 1})
    await store.set("chat:u1:C2", {"b": 2})
    await store.set("auth:u1", {"c": 3})
    keys = await store.keys("chat:u1:")
    assert set(keys) == {"chat:u1:C1", "chat:u1:C2"}


# ── SQLiteStore ────────────────────────────────────────────────────────────────

@pytest.fixture
def db_path(tmp_path):
    return str(tmp_path / "test.db")


async def test_sqlite_set_and_get(db_path):
    store = SQLiteStore(db_path)
    await store.set("k", {"hello": "world"})
    assert await store.get("k") == {"hello": "world"}


async def test_sqlite_overwrite(db_path):
    store = SQLiteStore(db_path)
    await store.set("k", {"v": 1})
    await store.set("k", {"v": 2})
    assert await store.get("k") == {"v": 2}


async def test_sqlite_delete(db_path):
    store = SQLiteStore(db_path)
    await store.set("k", {"v": 1})
    await store.delete("k")
    assert await store.get("k") is None


async def test_sqlite_keys_prefix(db_path):
    store = SQLiteStore(db_path)
    await store.set("chat:u1:C1", {})
    await store.set("chat:u1:C2", {})
    await store.set("auth:u1", {})
    keys = await store.keys("chat:u1:")
    assert set(keys) == {"chat:u1:C1", "chat:u1:C2"}
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_store.py -v

Ожидаем: ImportError: cannot import name 'InMemoryStore' from 'core.store'

  • Step 3: Создать core/store.py
# core/store.py
from __future__ import annotations

import json
import sqlite3
from typing import Protocol


class StateStore(Protocol):
    async def get(self, key: str) -> dict | None: ...
    async def set(self, key: str, value: dict) -> None: ...
    async def delete(self, key: str) -> None: ...
    async def keys(self, prefix: str) -> list[str]: ...


class InMemoryStore:
    def __init__(self) -> None:
        self._data: dict[str, dict] = {}

    async def get(self, key: str) -> dict | None:
        return self._data.get(key)

    async def set(self, key: str, value: dict) -> None:
        self._data[key] = value

    async def delete(self, key: str) -> None:
        self._data.pop(key, None)

    async def keys(self, prefix: str) -> list[str]:
        return [k for k in self._data if k.startswith(prefix)]


class SQLiteStore:
    def __init__(self, db_path: str) -> None:
        self._db_path = db_path
        self._init_db()

    def _init_db(self) -> None:
        conn = sqlite3.connect(self._db_path)
        conn.execute(
            "CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
        )
        conn.commit()
        conn.close()

    async def get(self, key: str) -> dict | None:
        conn = sqlite3.connect(self._db_path)
        row = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone()
        conn.close()
        return json.loads(row[0]) if row else None

    async def set(self, key: str, value: dict) -> None:
        conn = sqlite3.connect(self._db_path)
        conn.execute(
            "INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)",
            (key, json.dumps(value, default=str)),
        )
        conn.commit()
        conn.close()

    async def delete(self, key: str) -> None:
        conn = sqlite3.connect(self._db_path)
        conn.execute("DELETE FROM kv WHERE key = ?", (key,))
        conn.commit()
        conn.close()

    async def keys(self, prefix: str) -> list[str]:
        conn = sqlite3.connect(self._db_path)
        rows = conn.execute(
            "SELECT key FROM kv WHERE key LIKE ?", (prefix + "%",)
        ).fetchall()
        conn.close()
        return [row[0] for row in rows]
  • Step 4: Запустить тесты
uv run pytest tests/core/test_store.py -v

Ожидаем: 9 PASSED

  • Step 5: Commit
git add core/store.py tests/core/test_store.py
git commit -m "feat: add core/store.py — StateStore, InMemoryStore, SQLiteStore"

Task 4: core/chat.py — ChatManager

Files:

  • Create: core/chat.py

  • Create: tests/core/test_chat.py

  • Step 1: Написать тест

# tests/core/test_chat.py
import pytest
from core.chat import ChatManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient


@pytest.fixture
def mgr():
    return ChatManager(MockPlatformClient(), InMemoryStore())


async def test_get_or_create_new_chat(mgr):
    ctx = await mgr.get_or_create("u1", "C1", "telegram", "topic-123")
    assert ctx.chat_id == "C1"
    assert ctx.platform == "telegram"
    assert ctx.is_archived is False


async def test_get_or_create_idempotent(mgr):
    c1 = await mgr.get_or_create("u1", "C1", "telegram", "t1")
    c2 = await mgr.get_or_create("u1", "C1", "telegram", "t1")
    assert c1.chat_id == c2.chat_id
    assert c1.display_name == c2.display_name


async def test_get_or_create_with_custom_name(mgr):
    ctx = await mgr.get_or_create("u1", "C1", "telegram", "t1", name="Анализ рынка")
    assert ctx.display_name == "Анализ рынка"


async def test_rename_chat(mgr):
    await mgr.get_or_create("u1", "C1", "telegram", "t1")
    ctx = await mgr.rename("C1", "Новое название")
    assert ctx.display_name == "Новое название"


async def test_archive_chat(mgr):
    await mgr.get_or_create("u1", "C1", "telegram", "t1")
    await mgr.archive("C1")
    ctx = await mgr.get("C1")
    assert ctx is not None
    assert ctx.is_archived is True


async def test_list_active_excludes_archived(mgr):
    await mgr.get_or_create("u1", "C1", "telegram", "t1")
    await mgr.get_or_create("u1", "C2", "telegram", "t2")
    await mgr.archive("C2")
    chats = await mgr.list_active("u1")
    ids = [c.chat_id for c in chats]
    assert "C1" in ids
    assert "C2" not in ids
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_chat.py -v

Ожидаем: ImportError: cannot import name 'ChatManager' from 'core.chat'

  • Step 3: Создать core/chat.py
# core/chat.py
from __future__ import annotations

from datetime import datetime

import structlog

from core.protocol import ChatContext
from core.store import StateStore

logger = structlog.get_logger(__name__)


def _to_dict(ctx: ChatContext) -> dict:
    return {
        "chat_id": ctx.chat_id,
        "display_name": ctx.display_name,
        "platform": ctx.platform,
        "surface_ref": ctx.surface_ref,
        "created_at": ctx.created_at.isoformat(),
        "is_archived": ctx.is_archived,
    }


def _from_dict(d: dict) -> ChatContext:
    return ChatContext(
        chat_id=d["chat_id"],
        display_name=d["display_name"],
        platform=d["platform"],
        surface_ref=d["surface_ref"],
        created_at=datetime.fromisoformat(d["created_at"]),
        is_archived=d.get("is_archived", False),
    )


class ChatManager:
    """
    Управляет метаданными чатов (C1/C2/C3 в workspace).
    НЕ управляет lifecycle контейнера — это дело Master'а на стороне платформы.
    """

    def __init__(self, platform: object, store: StateStore) -> None:
        self._store = store

    def _key(self, user_id: str, chat_id: str) -> str:
        return f"chat:{user_id}:{chat_id}"

    async def get_or_create(
        self,
        user_id: str,
        chat_id: str,
        platform: str,
        surface_ref: str,
        name: str | None = None,
    ) -> ChatContext:
        key = self._key(user_id, chat_id)
        stored = await self._store.get(key)
        if stored:
            return _from_dict(stored)

        ctx = ChatContext(
            chat_id=chat_id,
            display_name=name or f"Чат {chat_id}",
            platform=platform,
            surface_ref=surface_ref,
            created_at=datetime.utcnow(),
        )
        await self._store.set(key, _to_dict(ctx))
        logger.info("Chat created", chat_id=chat_id, user_id=user_id)
        return ctx

    async def get(self, chat_id: str, user_id: str | None = None) -> ChatContext | None:
        # Try direct key if user_id provided
        if user_id:
            stored = await self._store.get(self._key(user_id, chat_id))
            return _from_dict(stored) if stored else None
        # Scan by chat_id suffix (slower, use only when user_id unknown)
        keys = await self._store.keys("chat:")
        for key in keys:
            if key.endswith(f":{chat_id}"):
                stored = await self._store.get(key)
                if stored:
                    return _from_dict(stored)
        return None

    async def rename(self, chat_id: str, name: str, user_id: str | None = None) -> ChatContext:
        ctx = await self.get(chat_id, user_id)
        if not ctx:
            raise ValueError(f"Chat {chat_id} not found")
        ctx.display_name = name
        # Resolve key
        keys = await self._store.keys("chat:")
        for key in keys:
            if key.endswith(f":{chat_id}"):
                await self._store.set(key, _to_dict(ctx))
                break
        return ctx

    async def archive(self, chat_id: str, user_id: str | None = None) -> None:
        ctx = await self.get(chat_id, user_id)
        if not ctx:
            raise ValueError(f"Chat {chat_id} not found")
        ctx.is_archived = True
        keys = await self._store.keys("chat:")
        for key in keys:
            if key.endswith(f":{chat_id}"):
                await self._store.set(key, _to_dict(ctx))
                break

    async def list_active(self, user_id: str) -> list[ChatContext]:
        keys = await self._store.keys(f"chat:{user_id}:")
        chats = []
        for key in keys:
            stored = await self._store.get(key)
            if stored and not stored.get("is_archived"):
                chats.append(_from_dict(stored))
        return chats
  • Step 4: Запустить тесты
uv run pytest tests/core/test_chat.py -v

Ожидаем: 6 PASSED

  • Step 5: Commit
git add core/chat.py tests/core/test_chat.py
git commit -m "feat: add core/chat.py — ChatManager for chat metadata (C1/C2/C3)"

Task 5: core/auth.py — AuthManager

Files:

  • Create: core/auth.py

  • Create: tests/core/test_auth.py

  • Step 1: Написать тест

# tests/core/test_auth.py
import pytest
from core.auth import AuthManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient


@pytest.fixture
def mgr():
    return AuthManager(MockPlatformClient(), InMemoryStore())


async def test_not_authenticated_initially(mgr):
    assert await mgr.is_authenticated("u1") is False


async def test_start_flow_returns_pending(mgr):
    flow = await mgr.start_flow("u1", "telegram")
    assert flow.state == "pending"
    assert flow.user_id == "u1"


async def test_confirm_sets_confirmed(mgr):
    await mgr.start_flow("u1", "telegram")
    flow = await mgr.confirm("u1")
    assert flow.state == "confirmed"


async def test_is_authenticated_after_confirm(mgr):
    await mgr.start_flow("u1", "telegram")
    await mgr.confirm("u1")
    assert await mgr.is_authenticated("u1") is True


async def test_confirm_without_start_flow(mgr):
    # Mock auto-confirms even without explicit start_flow
    flow = await mgr.confirm("u1")
    assert flow.state == "confirmed"
    assert await mgr.is_authenticated("u1") is True
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_auth.py -v

Ожидаем: ImportError

  • Step 3: Создать core/auth.py
# core/auth.py
from __future__ import annotations

import structlog

from core.protocol import AuthFlow
from core.store import StateStore

logger = structlog.get_logger(__name__)


class AuthManager:
    def __init__(self, platform: object, store: StateStore) -> None:
        self._store = store

    async def start_flow(self, user_id: str, platform: str) -> AuthFlow:
        flow = AuthFlow(user_id=user_id, platform=platform, state="pending")
        await self._store.set(f"auth:{user_id}", _to_dict(flow))
        return flow

    async def confirm(self, user_id: str) -> AuthFlow:
        """В моке — автоматическое подтверждение. В реальном SDK — валидация кода."""
        stored = await self._store.get(f"auth:{user_id}")
        if not stored:
            stored = {"user_id": user_id, "platform": "unknown", "state": "pending", "platform_user_id": None}

        stored["state"] = "confirmed"
        stored["platform_user_id"] = f"plt_{user_id}"
        await self._store.set(f"auth:{user_id}", stored)
        return _from_dict(stored)

    async def is_authenticated(self, user_id: str) -> bool:
        stored = await self._store.get(f"auth:{user_id}")
        return stored is not None and stored.get("state") == "confirmed"


def _to_dict(flow: AuthFlow) -> dict:
    return {
        "user_id": flow.user_id,
        "platform": flow.platform,
        "state": flow.state,
        "platform_user_id": flow.platform_user_id,
    }


def _from_dict(d: dict) -> AuthFlow:
    return AuthFlow(
        user_id=d["user_id"],
        platform=d["platform"],
        state=d["state"],
        platform_user_id=d.get("platform_user_id"),
    )
  • Step 4: Запустить тесты
uv run pytest tests/core/test_auth.py -v

Ожидаем: 5 PASSED

  • Step 5: Commit
git add core/auth.py tests/core/test_auth.py
git commit -m "feat: add core/auth.py — AuthManager state machine"

Task 6: core/settings.py — SettingsManager

Files:

  • Create: core/settings.py

  • Create: tests/core/test_settings.py

  • Step 1: Написать тест

# tests/core/test_settings.py
import pytest
from core.settings import SettingsManager
from core.store import InMemoryStore
from core.protocol import SettingsAction
from platform.mock import MockPlatformClient


@pytest.fixture
def mgr():
    return SettingsManager(MockPlatformClient(), InMemoryStore())


async def test_get_returns_defaults(mgr):
    settings = await mgr.get("u1")
    assert "web-search" in settings.skills


async def test_apply_toggle_skill(mgr):
    action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
    await mgr.apply("u1", action)
    settings = await mgr.get("u1")
    assert settings.skills.get("browser") is True


async def test_apply_invalidates_cache(mgr):
    # First fetch (caches)
    s1 = await mgr.get("u1")
    initial = s1.skills.get("browser", False)

    # Toggle
    action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": not initial})
    await mgr.apply("u1", action)

    # Cache must be invalid — next get fetches fresh
    s2 = await mgr.get("u1")
    assert s2.skills.get("browser") == (not initial)
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_settings.py -v

Ожидаем: ImportError

  • Step 3: Создать core/settings.py
# core/settings.py
from __future__ import annotations

import structlog

from core.protocol import SettingsAction
from core.store import StateStore
from platform.interface import PlatformClient, UserSettings

logger = structlog.get_logger(__name__)


class SettingsManager:
    def __init__(self, platform: PlatformClient, store: StateStore) -> None:
        self._platform = platform
        self._store = store

    async def get(self, user_id: str) -> UserSettings:
        cached = await self._store.get(f"settings:{user_id}")
        if cached:
            return UserSettings(**cached)
        settings = await self._platform.get_settings(user_id)
        await self._store.set(f"settings:{user_id}", settings.model_dump())
        return settings

    async def apply(self, user_id: str, action: SettingsAction) -> None:
        await self._platform.update_settings(user_id, action)
        await self._store.delete(f"settings:{user_id}")  # invalidate cache
        logger.info("Settings applied", user_id=user_id, action=action.action)
  • Step 4: Запустить тесты
uv run pytest tests/core/test_settings.py -v

Ожидаем: 3 PASSED

  • Step 5: Commit
git add core/settings.py tests/core/test_settings.py
git commit -m "feat: add core/settings.py — SettingsManager with cache invalidation"

Task 7: core/handler.py — EventDispatcher

Files:

  • Create: core/handler.py

  • Create: tests/core/test_dispatcher.py

  • Step 1: Написать тест

# tests/core/test_dispatcher.py
import pytest
from core.handler import EventDispatcher
from core.protocol import (
    IncomingCommand, IncomingMessage, IncomingCallback,
    OutgoingMessage, Attachment,
)
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient


@pytest.fixture
def platform():
    return MockPlatformClient()


@pytest.fixture
def dispatcher(platform):
    store = InMemoryStore()
    return EventDispatcher(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=AuthManager(platform, store),
        settings_mgr=SettingsManager(platform, store),
    )


async def test_dispatch_command_to_handler(dispatcher):
    called_with = {}

    async def my_handler(event, chat_mgr, auth_mgr, settings_mgr, platform):
        called_with["event"] = event
        return [OutgoingMessage(chat_id=event.chat_id, text="ok")]

    dispatcher.register(IncomingCommand, "ping", my_handler)
    cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="ping")
    result = await dispatcher.dispatch(cmd)

    assert called_with["event"] is cmd
    assert len(result) == 1
    assert result[0].text == "ok"


async def test_dispatch_unknown_command_returns_empty(dispatcher):
    cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="unknown")
    result = await dispatcher.dispatch(cmd)
    assert result == []


async def test_dispatch_message_to_catchall(dispatcher):
    async def catch_all(event, **kwargs):
        return [OutgoingMessage(chat_id=event.chat_id, text="caught")]

    dispatcher.register(IncomingMessage, "*", catch_all)
    msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hello")
    result = await dispatcher.dispatch(msg)
    assert result[0].text == "caught"


async def test_dispatch_routes_by_attachment_type(dispatcher):
    async def audio_handler(event, **kwargs):
        return [OutgoingMessage(chat_id=event.chat_id, text="audio")]

    async def catch_all(event, **kwargs):
        return [OutgoingMessage(chat_id=event.chat_id, text="text")]

    dispatcher.register(IncomingMessage, "audio", audio_handler)
    dispatcher.register(IncomingMessage, "*", catch_all)

    audio_msg = IncomingMessage(
        user_id="u1", platform="telegram", chat_id="C1", text="",
        attachments=[Attachment(type="audio")],
    )
    text_msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")

    assert (await dispatcher.dispatch(audio_msg))[0].text == "audio"
    assert (await dispatcher.dispatch(text_msg))[0].text == "text"


async def test_dispatch_callback_by_action(dispatcher):
    async def confirm_handler(event, **kwargs):
        return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")]

    dispatcher.register(IncomingCallback, "confirm", confirm_handler)
    cb = IncomingCallback(user_id="u1", platform="telegram", chat_id="C1", action="confirm")
    result = await dispatcher.dispatch(cb)
    assert result[0].text == "confirmed"
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_dispatcher.py -v

Ожидаем: ImportError

  • Step 3: Создать core/handler.py
# core/handler.py
from __future__ import annotations

from typing import Awaitable, Callable

import structlog

from core.auth import AuthManager
from core.chat import ChatManager
from core.protocol import (
    IncomingCallback,
    IncomingCommand,
    IncomingEvent,
    IncomingMessage,
    OutgoingEvent,
)
from core.settings import SettingsManager
from platform.interface import PlatformClient

logger = structlog.get_logger(__name__)

HandlerFn = Callable[..., Awaitable[list[OutgoingEvent]]]


class EventDispatcher:
    def __init__(
        self,
        platform: PlatformClient,
        chat_mgr: ChatManager,
        auth_mgr: AuthManager,
        settings_mgr: SettingsManager,
    ) -> None:
        self._platform = platform
        self._chat_mgr = chat_mgr
        self._auth_mgr = auth_mgr
        self._settings_mgr = settings_mgr
        self._handlers: dict[type, dict[str, HandlerFn]] = {
            IncomingCommand: {},
            IncomingMessage: {},
            IncomingCallback: {},
        }

    def register(self, event_type: type, key: str, handler: HandlerFn) -> None:
        self._handlers[event_type][key] = handler

    async def dispatch(self, event: IncomingEvent) -> list[OutgoingEvent]:
        event_type = type(event)
        handlers = self._handlers.get(event_type, {})
        key = self._routing_key(event)
        handler = handlers.get(key) or handlers.get("*")

        if handler is None:
            logger.warning("No handler registered", event_type=event_type.__name__, key=key)
            return []

        return await handler(
            event=event,
            chat_mgr=self._chat_mgr,
            auth_mgr=self._auth_mgr,
            settings_mgr=self._settings_mgr,
            platform=self._platform,
        )

    def _routing_key(self, event: IncomingEvent) -> str:
        if isinstance(event, IncomingCommand):
            return event.command
        if isinstance(event, IncomingCallback):
            return event.action
        if isinstance(event, IncomingMessage) and event.attachments:
            return event.attachments[0].type
        return "*"
  • Step 4: Запустить тесты
uv run pytest tests/core/test_dispatcher.py -v

Ожидаем: 5 PASSED

  • Step 5: Commit
git add core/handler.py tests/core/test_dispatcher.py
git commit -m "feat: add core/handler.py — EventDispatcher routing by type+key"

Task 8: core/handlers/ — все обработчики

Files:

  • Create: core/handlers/start.py

  • Create: core/handlers/message.py

  • Create: core/handlers/chat.py

  • Create: core/handlers/callback.py

  • Create: core/handlers/settings.py

  • Create: tests/core/test_voice_slot.py

  • Step 1: Написать тест voice slot

# tests/core/test_voice_slot.py
import pytest
from core.protocol import IncomingMessage, Attachment, OutgoingMessage
from core.handlers.message import handle_message
from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from platform.mock import MockPlatformClient


@pytest.fixture
def deps():
    platform = MockPlatformClient()
    store = InMemoryStore()
    auth_mgr = AuthManager(platform, store)
    return dict(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=auth_mgr,
        settings_mgr=SettingsManager(platform, store),
    )


async def test_voice_message_returns_stub(deps):
    # Authenticate user first
    await deps["auth_mgr"].confirm("u1")

    msg = IncomingMessage(
        user_id="u1", platform="telegram", chat_id="C1", text="",
        attachments=[Attachment(type="audio", filename="voice.ogg")],
    )
    result = await handle_message(event=msg, **deps)
    assert len(result) == 1
    assert isinstance(result[0], OutgoingMessage)
    assert "голосов" in result[0].text.lower()


async def test_text_message_calls_platform(deps):
    await deps["auth_mgr"].confirm("u1")

    msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="Привет!")
    result = await handle_message(event=msg, **deps)

    texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
    assert any("[MOCK]" in t for t in texts)


async def test_unauthenticated_user_gets_start_prompt(deps):
    msg = IncomingMessage(user_id="new_user", platform="telegram", chat_id="C1", text="hello")
    result = await handle_message(event=msg, **deps)
    assert len(result) == 1
    assert "/start" in result[0].text
  • Step 2: Запустить — убедиться что FAIL
uv run pytest tests/core/test_voice_slot.py -v

Ожидаем: ImportError

  • Step 3: Создать core/handlers/message.py
# core/handlers/message.py
from __future__ import annotations

from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping


async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    if not await auth_mgr.is_authenticated(event.user_id):
        return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]

    # Voice slot fallback: audio attachment without registered voice_handler
    if event.attachments and event.attachments[0].type == "audio":
        return [OutgoingMessage(
            chat_id=event.chat_id,
            text="Голосовые сообщения скоро поддержим.",
            parse_mode="plain",
        )]

    response = await platform.send_message(
        user_id=event.user_id,
        chat_id=event.chat_id,
        text=event.text,
        attachments=[],
    )

    return [
        OutgoingTyping(chat_id=event.chat_id, is_typing=False),
        OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"),
    ]
  • Step 4: Запустить voice slot тесты
uv run pytest tests/core/test_voice_slot.py -v

Ожидаем: 3 PASSED

  • Step 5: Создать остальные обработчики
# core/handlers/start.py
from __future__ import annotations

from core.protocol import IncomingCommand, OutgoingMessage


async def handle_start(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    user = await platform.get_or_create_user(event.user_id, event.platform)
    await auth_mgr.confirm(event.user_id)
    name = user.display_name or event.user_id
    text = (
        f"Добро пожаловать, {name}! Я агент Lambda. Напишите что-нибудь чтобы начать."
        if user.is_new
        else f"С возвращением, {name}!"
    )
    return [OutgoingMessage(chat_id=event.chat_id, text=text)]
# core/handlers/chat.py
from __future__ import annotations

from core.protocol import IncomingCommand, OutgoingMessage


async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    if not await auth_mgr.is_authenticated(event.user_id):
        return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
    name = " ".join(event.args) if event.args else None
    ctx = await chat_mgr.get_or_create(
        user_id=event.user_id,
        chat_id=event.chat_id,
        platform=event.platform,
        surface_ref=event.chat_id,
        name=name,
    )
    return [OutgoingMessage(chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name}")]


async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    if not event.args:
        return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")]
    ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
    return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]


async def handle_archive(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    await chat_mgr.archive(event.chat_id)
    return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")]


async def handle_list_chats(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    chats = await chat_mgr.list_active(event.user_id)
    if not chats:
        return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")]
    lines = [f"• {c.display_name} ({c.chat_id})" for c in chats]
    return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
# core/handlers/callback.py
from __future__ import annotations

from core.protocol import IncomingCallback, OutgoingMessage, SettingsAction


async def handle_confirm(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    action_id = event.payload.get("action_id", "unknown")
    return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие подтверждено (id: {action_id}).")]


async def handle_cancel(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    action_id = event.payload.get("action_id", "unknown")
    return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие отменено (id: {action_id}).")]


async def handle_toggle_skill(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    skill = event.payload.get("skill")
    enabled = event.payload.get("enabled", True)
    if not skill:
        return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не указан навык.")]
    action = SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled})
    await settings_mgr.apply(event.user_id, action)
    state = "включён" if enabled else "выключен"
    return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")]
# core/handlers/settings.py
from __future__ import annotations

from core.protocol import IncomingCommand, OutgoingMessage, OutgoingUI, UIButton


async def handle_settings(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    return [OutgoingUI(
        chat_id=event.chat_id,
        text="⚙️ Настройки",
        buttons=[
            UIButton(label="🔗 Коннекторы", action="settings_connectors", payload={}, style="secondary"),
            UIButton(label="🧩 Скиллы",     action="settings_skills",     payload={}, style="secondary"),
            UIButton(label="🧠 Личность",   action="settings_soul",       payload={}, style="secondary"),
            UIButton(label="🔒 Безопасность", action="settings_safety",   payload={}, style="secondary"),
            UIButton(label="💳 Подписка",   action="settings_plan",       payload={}, style="secondary"),
        ],
    )]


async def handle_settings_skills(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
    s = await settings_mgr.get(event.user_id)
    lines = [("✅" if on else "❌") + f" {name}" for name, on in s.skills.items()]
    text = "🧩 Скиллы\n\n" + ("\n".join(lines) or "Нет доступных скиллов")
    return [OutgoingMessage(chat_id=event.chat_id, text=text)]
  • Step 6: Обновить core/handlers/__init__.py — зарегистрировать все обработчики
# core/handlers/__init__.py
from __future__ import annotations

from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
from core.handlers import callback, chat, message, settings, start


def register_all(dispatcher: EventDispatcher) -> None:
    # Commands
    dispatcher.register(IncomingCommand, "start",    start.handle_start)
    dispatcher.register(IncomingCommand, "new",      chat.handle_new_chat)
    dispatcher.register(IncomingCommand, "rename",   chat.handle_rename)
    dispatcher.register(IncomingCommand, "archive",  chat.handle_archive)
    dispatcher.register(IncomingCommand, "chats",    chat.handle_list_chats)
    dispatcher.register(IncomingCommand, "settings", settings.handle_settings)

    # Messages — catch-all (voice falls back here when no voice_handler registered)
    dispatcher.register(IncomingMessage, "*", message.handle_message)

    # Callbacks
    dispatcher.register(IncomingCallback, "confirm",      callback.handle_confirm)
    dispatcher.register(IncomingCallback, "cancel",       callback.handle_cancel)
    dispatcher.register(IncomingCallback, "toggle_skill", callback.handle_toggle_skill)
  • Step 7: Запустить все тесты
uv run pytest tests/ -v

Ожидаем: все PASSED

  • Step 8: Commit
git add core/handlers/ tests/core/test_voice_slot.py
git commit -m "feat: add core/handlers/ — start, message, chat, callback, settings + voice slot"

Task 9: Integration smoke test

Files:

  • Create: tests/core/test_integration.py

  • Step 1: Написать интеграционный тест

# tests/core/test_integration.py
"""
Smoke test: полный цикл через dispatcher + реальные managers + MockPlatformClient.
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
from platform.mock import MockPlatformClient
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
    IncomingCommand, IncomingMessage, IncomingCallback,
    OutgoingMessage, OutgoingUI, OutgoingTyping,
    Attachment, SettingsAction,
)


@pytest.fixture
def dispatcher():
    platform = MockPlatformClient()
    store = InMemoryStore()
    d = EventDispatcher(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=AuthManager(platform, store),
        settings_mgr=SettingsManager(platform, store),
    )
    register_all(d)
    return d


async def test_full_flow_start_then_message(dispatcher):
    # /start
    start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start")
    result = await dispatcher.dispatch(start)
    assert any(isinstance(r, OutgoingMessage) for r in result)

    # Обычное сообщение после старта
    msg = IncomingMessage(user_id="tg_123", platform="telegram", chat_id="C1", text="Привет!")
    result = await dispatcher.dispatch(msg)
    texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
    assert any("[MOCK]" in t for t in texts)


async def test_new_chat_command(dispatcher):
    start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
    await dispatcher.dispatch(start)

    new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"])
    result = await dispatcher.dispatch(new)
    assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))


async def test_settings_menu(dispatcher):
    start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
    await dispatcher.dispatch(start)

    s = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="settings")
    result = await dispatcher.dispatch(s)
    assert any(isinstance(r, OutgoingUI) for r in result)


async def test_voice_message_fallback(dispatcher):
    start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
    await dispatcher.dispatch(start)

    voice = IncomingMessage(
        user_id="u1", platform="telegram", chat_id="C1", text="",
        attachments=[Attachment(type="audio")],
    )
    result = await dispatcher.dispatch(voice)
    assert any("голосов" in r.text.lower() for r in result if isinstance(r, OutgoingMessage))


async def test_toggle_skill_callback(dispatcher):
    start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
    await dispatcher.dispatch(start)

    cb = IncomingCallback(
        user_id="u1", platform="telegram", chat_id="C1",
        action="toggle_skill", payload={"skill": "browser", "enabled": True},
    )
    result = await dispatcher.dispatch(cb)
    assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))
  • Step 2: Запустить интеграционный тест
uv run pytest tests/core/test_integration.py -v

Ожидаем: 5 PASSED

  • Step 3: Запустить все тесты финально
uv run pytest tests/ -v --tb=short

Ожидаем: все PASSED, 0 errors

  • Step 4: Final commit
git add tests/core/test_integration.py
git commit -m "test: add integration smoke test for full dispatcher flow"