From 2ffed55c322ed3c0db90305c02007863a26f4037 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:07:39 -0700 Subject: [PATCH] feat: server-side ui_meta on profiles.list/configure (#85440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: server-side ui_meta on profiles.list/configure Roster UIs built on profiles.* have per-profile presentation state (avatar, accent color, display title, pet) with nowhere server-side to live — client plugin storage paints a different roster on every machine. profiles.configure now accepts ui_meta (merged key-wise into profile.yaml's ui_meta block via the existing atomic_yaml_write path, null deletes a key, 64KB cap since it rides every roster paint) and profiles.list returns the block per row. Consumers namespace under their own key. No new files or config; profiles without the block are unchanged. * test: stop primary-runtime-restore tests probing live endpoints _make_agent left the compressor's lazy context-length resolution unmocked; for reachable base_urls (the nous portal test) the endpoint's 32K answer for the empty test model trips agent_init's 64K floor and fails the suite on network behavior. Pin get_model_context_length in the fixture. --- .../run_agent/test_primary_runtime_restore.py | 9 +++ tui_gateway/methods_profiles.py | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index 9cc8229aceb17..6154e5e0091bb 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -36,6 +36,15 @@ def _make_agent(fallback_model=None, provider="custom", base_url="https://my-llm patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), patch("run_agent.check_toolset_requirements", return_value={}), patch("run_agent.OpenAI"), + # Unit tests must not probe live endpoints. The compressor resolves + # context length lazily via a real network call against base_url; for + # reachable hosts (the nous portal case) the endpoint's answer for the + # empty test model (32K) trips agent_init's 64K floor and fails the + # test on network behavior, not code under test. + patch( + "agent.context_compressor.get_model_context_length", + return_value=200_000, + ), ): agent = AIAgent( api_key="test-key-12345678", diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 75eb967cd1085..12acaabd05c84 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -89,6 +89,23 @@ def _(rid, params: dict) -> dict: } if include_sessions: row["last_session"] = _latest_profile_session_row(p.path) + + # Client-agnostic UI metadata (avatars, accent colors, pinned + # order, …) — stored server-side in profile.yaml so every + # machine connecting to this gateway paints the same roster. + try: + import yaml as _yaml + from pathlib import Path as _Path + + meta_path = _Path(str(p.path)) / "profile.yaml" + if meta_path.is_file(): + with open(meta_path, "r", encoding="utf-8") as f: + raw_meta = _yaml.safe_load(f) or {} + ui_meta = raw_meta.get("ui_meta") + if isinstance(ui_meta, dict) and ui_meta: + row["ui_meta"] = ui_meta + except Exception: + pass out.append(row) return _ok(rid, {"profiles": out}) except Exception as e: @@ -382,6 +399,50 @@ def _(rid, params: dict) -> dict: applied = {} + if isinstance(params.get("ui_meta"), dict): + # Client-agnostic UI metadata (avatar/pet/etc.), merged key-wise + # into profile.yaml's ui_meta block. A key set to None deletes it. + # Size-capped: this rides profiles.list on every roster paint, so + # large blobs (e.g. raw base64 images) are rejected — persist big + # assets elsewhere and store a reference. + try: + import json as _json + + incoming = params["ui_meta"] + if len(_json.dumps(incoming)) > 65536: + applied["ui_meta"] = False + else: + import yaml as _yaml + + meta_path = profile_dir / "profile.yaml" + existing = {} + if meta_path.is_file(): + try: + with open(meta_path, "r", encoding="utf-8") as f: + loaded = _yaml.safe_load(f) or {} + if isinstance(loaded, dict): + existing = loaded + except Exception: + existing = {} + current = existing.get("ui_meta") + if not isinstance(current, dict): + current = {} + for key, value in incoming.items(): + if value is None: + current.pop(key, None) + else: + current[key] = value + if current: + existing["ui_meta"] = current + else: + existing.pop("ui_meta", None) + from utils import atomic_yaml_write + + atomic_yaml_write(meta_path, existing, sort_keys=False) + applied["ui_meta"] = True + except Exception: + applied["ui_meta"] = False + if isinstance(params.get("soul"), str): try: (profile_dir / "SOUL.md").write_text(params["soul"], encoding="utf-8")