diff --git a/sdk/prototype_state.py b/sdk/prototype_state.py new file mode 100644 index 0000000..4682bd9 --- /dev/null +++ b/sdk/prototype_state.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sdk.interface import User, UserSettings + +# Keep the prototype backend self-contained; do not import these from sdk.mock. +DEFAULT_SKILLS: dict[str, bool] = { + "web-search": True, + "fetch-url": True, + "email": False, + "browser": False, + "image-gen": False, + "files": True, +} +DEFAULT_SAFETY: dict[str, bool] = { + "email-send": True, + "file-delete": True, + "social-post": True, +} +DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""} +DEFAULT_PLAN: dict[str, Any] = { + "name": "Beta", + "tokens_used": 0, + "tokens_limit": 1000, +} + + +class PrototypeStateStore: + def __init__(self) -> None: + self._users: dict[str, User] = {} + self._settings: dict[str, dict[str, Any]] = {} + + async def get_or_create_user( + self, + *, + external_id: str, + platform: str, + display_name: str | None = None, + ) -> User: + key = f"{platform}:{external_id}" + existing = self._users.get(key) + if existing is not None: + return existing.model_copy(update={"is_new": False}) + + user = User( + user_id=f"usr-{platform}-{external_id}", + external_id=external_id, + platform=platform, + display_name=display_name, + created_at=datetime.now(UTC), + is_new=True, + ) + self._users[key] = user + return user + + async def get_settings(self, user_id: str) -> UserSettings: + stored = self._settings.get(user_id, {}) + return UserSettings( + skills={**DEFAULT_SKILLS, **stored.get("skills", {})}, + connectors=stored.get("connectors", {}), + soul={**DEFAULT_SOUL, **stored.get("soul", {})}, + safety={**DEFAULT_SAFETY, **stored.get("safety", {})}, + plan={**DEFAULT_PLAN, **stored.get("plan", {})}, + ) + + async def update_settings(self, user_id: str, action: Any) -> None: + settings = self._settings.setdefault(user_id, {}) + + if action.action == "toggle_skill": + skills = settings.setdefault("skills", DEFAULT_SKILLS.copy()) + skills[action.payload["skill"]] = action.payload.get("enabled", True) + elif action.action == "set_soul": + soul = settings.setdefault("soul", DEFAULT_SOUL.copy()) + soul[action.payload["field"]] = action.payload["value"] + elif action.action == "set_safety": + safety = settings.setdefault("safety", DEFAULT_SAFETY.copy()) + safety[action.payload["trigger"]] = action.payload.get("enabled", True) diff --git a/tests/platform/test_prototype_state.py b/tests/platform/test_prototype_state.py new file mode 100644 index 0000000..2a49375 --- /dev/null +++ b/tests/platform/test_prototype_state.py @@ -0,0 +1,75 @@ +import pytest + +from core.protocol import SettingsAction +from sdk.interface import UserSettings +from sdk.prototype_state import PrototypeStateStore + + +@pytest.mark.asyncio +async def test_get_or_create_user_is_stable_per_surface_identity(): + store = PrototypeStateStore() + + first = await store.get_or_create_user( + external_id="@alice:example.org", + platform="matrix", + display_name="Alice", + ) + second = await store.get_or_create_user( + external_id="@alice:example.org", + platform="matrix", + ) + + assert first.user_id == "usr-matrix-@alice:example.org" + assert first.is_new is True + assert second.user_id == first.user_id + assert second.is_new is False + assert second.display_name == "Alice" + + +@pytest.mark.asyncio +async def test_settings_defaults_match_existing_mock_shape(): + store = PrototypeStateStore() + + settings = await store.get_settings("usr-matrix-@alice:example.org") + + assert isinstance(settings, UserSettings) + assert settings.skills == { + "web-search": True, + "fetch-url": True, + "email": False, + "browser": False, + "image-gen": False, + "files": True, + } + assert settings.safety == { + "email-send": True, + "file-delete": True, + "social-post": True, + } + assert settings.soul == {"name": "Лямбда", "instructions": ""} + assert settings.plan == {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000} + + +@pytest.mark.asyncio +async def test_update_settings_supports_toggle_skill_and_setters(): + store = PrototypeStateStore() + + await store.update_settings( + "usr-matrix-@alice:example.org", + SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}), + ) + await store.update_settings( + "usr-matrix-@alice:example.org", + SettingsAction(action="set_soul", payload={"field": "instructions", "value": "Be concise"}), + ) + await store.update_settings( + "usr-matrix-@alice:example.org", + SettingsAction(action="set_safety", payload={"trigger": "social-post", "enabled": False}), + ) + + settings = await store.get_settings("usr-matrix-@alice:example.org") + + assert settings.skills["browser"] is True + assert settings.skills["web-search"] is True + assert settings.soul["instructions"] == "Be concise" + assert settings.safety["social-post"] is False