From 4153a47d0f55067feb6874c72492e5825bdc1723 Mon Sep 17 00:00:00 2001 From: s010mn Date: Fri, 22 May 2026 20:23:22 +0800 Subject: [PATCH 1/5] feat: new_session() reads display.personality from config as default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When display.personality is set in config.yaml (e.g. personality: taleb), new sessions now inherit it automatically instead of starting with personality=None and requiring an explicit /personality command. This makes the selected personality sticky across new conversations rather than requiring per-session activation. Behavior: - display.personality values 'none', 'default', 'neutral', '' are treated as no personality (personality=None), matching TUI gateway semantics. - Config read is wrapped in try/except — if it fails, personality falls back to None (no crash, no regression). - Case-insensitive: 'Taleb' normalizes to 'taleb'. The /personality slash command still works for per-session overrides as before; this change only affects the initial default. --- api/models.py | 14 ++++ tests/test_default_personality.py | 123 ++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 tests/test_default_personality.py diff --git a/api/models.py b/api/models.py index 03334797..0d1d2d4c 100644 --- a/api/models.py +++ b/api/models.py @@ -1848,6 +1848,19 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p effective_model, effective_model_provider = _profile_default_model_state(profile) if model_provider: effective_model_provider = model_provider + + # Read default personality from config display.personality + _default_personality = None + try: + from api.config import get_config as _get_cfg_for_personality + _cfg_personality = (_get_cfg_for_personality().get('display') or {}).get('personality') + if _cfg_personality and isinstance(_cfg_personality, str): + _cfg_personality = _cfg_personality.strip().lower() + if _cfg_personality and _cfg_personality not in ('default', 'none', 'neutral'): + _default_personality = _cfg_personality + except Exception: + pass + wt = worktree_info if isinstance(worktree_info, dict) else None workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace s = Session( @@ -1856,6 +1869,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p model_provider=effective_model_provider, profile=profile, project_id=project_id, + personality=_default_personality, worktree_path=wt.get('path') if wt else None, worktree_branch=wt.get('branch') if wt else None, worktree_repo_root=wt.get('repo_root') if wt else None, diff --git a/tests/test_default_personality.py b/tests/test_default_personality.py new file mode 100644 index 00000000..28169a0a --- /dev/null +++ b/tests/test_default_personality.py @@ -0,0 +1,123 @@ +"""Test that new_session() reads display.personality from config and uses it as default. + +Regression test for the feature that makes /personality taleb sticky across +new sessions — when display.personality is set in config.yaml, every new +session should inherit it without requiring an explicit /personality command. +""" + +import pytest +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# R1: new_session() inherits display.personality from config +# --------------------------------------------------------------------------- + +def test_new_session_reads_default_personality_from_config(): + """When display.personality is set to 'taleb', new_session() should + create a Session with personality='taleb'.""" + import api.models as m + import api.config as cfg_mod + + _cfg = { + "display": {"personality": "taleb"}, + "agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb", "tone": "blunt"}}}, + } + + with patch.object(cfg_mod, "get_config", return_value=_cfg), \ + patch.object(m.Session, "save", return_value=None): + s = m.new_session(workspace="/tmp/test-personality") + + assert s.personality == "taleb", ( + f"Expected personality='taleb', got {s.personality!r}" + ) + + +# --------------------------------------------------------------------------- +# R2: 'none', 'default', 'neutral' are treated as no personality +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("personality_value", ["none", "default", "neutral", ""]) +def test_new_session_ignores_neutral_personality_values(personality_value): + """Values like 'none', 'default', 'neutral', and '' should NOT be set as + the session personality — they mean 'no personality overlay'.""" + + import api.models as m + import api.config as cfg_mod + + _cfg = { + "display": {"personality": personality_value}, + "agent": {"personalities": {}}, + } + + with patch.object(cfg_mod, "get_config", return_value=_cfg), \ + patch.object(m.Session, "save", return_value=None): + s = m.new_session(workspace="/tmp/test-personality-neutral") + + assert s.personality is None, ( + f"Expected None for display.personality={personality_value!r}, " + f"got {s.personality!r}" + ) + + +# --------------------------------------------------------------------------- +# R3: Missing display.personality → personality=None +# --------------------------------------------------------------------------- + +def test_new_session_no_personality_when_config_missing(): + """When config has no display.personality (or display section is absent), + new_session() should set personality=None.""" + + import api.models as m + import api.config as cfg_mod + + _cfg = {"agent": {"personalities": {}}} # No display section at all + + with patch.object(cfg_mod, "get_config", return_value=_cfg), \ + patch.object(m.Session, "save", return_value=None): + s = m.new_session(workspace="/tmp/test-personality-missing") + + assert s.personality is None + + +# --------------------------------------------------------------------------- +# R4: Config exception is handled gracefully → personality=None +# --------------------------------------------------------------------------- + +def test_new_session_handles_config_exception_gracefully(): + """If get_config() raises, we should still get a valid session with + personality=None (the try/except should swallow the error).""" + + import api.models as m + import api.config as cfg_mod + + def _boom(): + raise RuntimeError("config exploded") + + with patch.object(cfg_mod, "get_config", side_effect=_boom), \ + patch.object(m.Session, "save", return_value=None): + s = m.new_session(workspace="/tmp/test-personality-boom") + + assert s.personality is None + + +# --------------------------------------------------------------------------- +# R5: display.personality is case-insensitive +# --------------------------------------------------------------------------- + +def test_new_session_personality_is_case_insensitive(): + """display.personality='Taleb' should be normalized to 'taleb'.""" + + import api.models as m + import api.config as cfg_mod + + _cfg = { + "display": {"personality": "Taleb"}, + "agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb"}}}, + } + + with patch.object(cfg_mod, "get_config", return_value=_cfg), \ + patch.object(m.Session, "save", return_value=None): + s = m.new_session(workspace="/tmp/test-personality-case") + + assert s.personality == "taleb" \ No newline at end of file From 56575bd3932fee6267e0576655069af0df650dd8 Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Thu, 21 May 2026 15:38:03 +0800 Subject: [PATCH 2/5] feat: sort configured/custom providers to top in model picker and settings --- api/config.py | 30 ++++++++++++++++++++++++++++++ api/providers.py | 12 ++++++++++++ 2 files changed, 42 insertions(+) diff --git a/api/config.py b/api/config.py index 24ef46a8..784cec70 100644 --- a/api/config.py +++ b/api/config.py @@ -4003,6 +4003,36 @@ def get_available_models() -> dict: or (g.get("provider_id") or "").startswith("custom:") ] + # Sort groups: active provider first, then custom:* providers, + # then providers with configured keys, then the rest alphabetically. + _providers_with_keys: set[str] = set() + try: + _pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {} + if isinstance(_pool, dict): + for _pid in _pool: + _providers_with_keys.add(_resolve_provider_alias(str(_pid))) + except Exception: + pass + try: + _cfg_providers = cfg.get("providers", {}) + if isinstance(_cfg_providers, dict): + for _pk, _pv in _cfg_providers.items(): + if isinstance(_pv, dict) and (_pv.get("api_key") or _pv.get("key_env")): + _providers_with_keys.add(_resolve_provider_alias(str(_pk))) + except Exception: + pass + + def _group_sort_key(g): + pid = g.get("provider_id") or "" + if pid == active_provider: + return (0, pid) + if pid.startswith("custom:"): + return (1, pid) + if pid in _providers_with_keys: + return (2, pid) + return (3, pid) + groups.sort(key=_group_sort_key) + # 12. Include model aliases so the WebUI frontend can resolve them. model_aliases: dict[str, str] = {} try: diff --git a/api/providers.py b/api/providers.py index dc8066dc..200000d5 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1996,6 +1996,18 @@ def get_providers() -> dict[str, Any]: if isinstance(model_cfg, dict): active_provider = model_cfg.get("provider") + # Sort providers: active first, then custom:*, then has_key, then rest. + def _provider_sort_key(p): + pid = p.get("id") or "" + if pid == active_provider: + return (0, pid) + if pid.startswith("custom:"): + return (1, pid) + if p.get("has_key"): + return (2, pid) + return (3, pid) + providers.sort(key=_provider_sort_key) + return { "providers": providers, "active_provider": active_provider, From 7e556614f97ac7ebf01206af2878a92569a90dd9 Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Thu, 21 May 2026 16:46:00 +0800 Subject: [PATCH 3/5] test: add sort order tests for providers and model picker --- tests/test_provider_sort_order.py | 254 ++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tests/test_provider_sort_order.py diff --git a/tests/test_provider_sort_order.py b/tests/test_provider_sort_order.py new file mode 100644 index 00000000..6bb04dc9 --- /dev/null +++ b/tests/test_provider_sort_order.py @@ -0,0 +1,254 @@ +"""Tests for provider/model-picker sort ordering. + +Feature: configured and custom providers float to the top in both +the Settings provider list and the model picker dropdown. + +Sort tiers (both endpoints): + 0 — active provider + 1 — custom:* providers + 2 — providers with a configured key (credential pool or config.yaml api_key) + 3 — everyone else, alphabetically by provider id +""" +from __future__ import annotations + +import sys +import types +from unittest import mock + +import pytest + +import api.config as config +import api.providers as providers_mod + + +# --------------------------------------------------------------------------- +# Helpers — lightweight stubs so we don't need a real hermes-agent install +# --------------------------------------------------------------------------- + +def _install_fake_hermes_cli(monkeypatch): + """Stub hermes_cli so detection is deterministic in tests.""" + fake_pkg = types.ModuleType("hermes_cli") + fake_pkg.__path__ = [] + + fake_models = types.ModuleType("hermes_cli.models") + fake_models.list_available_providers = lambda: [] + fake_models.provider_model_ids = lambda pid: [] + + fake_auth = types.ModuleType("hermes_cli.auth") + fake_auth.get_auth_status = lambda _pid: {} + + monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models) + monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth) + monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False) + monkeypatch.delitem(sys.modules, "agent", raising=False) + + config.invalidate_models_cache() + + +@pytest.fixture(autouse=True) +def _isolate_cache(): + """Invalidate the TTL model cache around each test.""" + try: + config.invalidate_models_cache() + except Exception: + pass + yield + try: + config.invalidate_models_cache() + except Exception: + pass + + +def _model_list(*ids): + """Helper: build [{"id": x, "label": x}, ...] for _PROVIDER_MODELS.""" + return [{"id": m, "label": m} for m in ids] + + +def _setup_config(tmp_path, monkeypatch, yaml_text, provider_models=None): + """Write config.yaml, point config module at it, install stubs.""" + _install_fake_hermes_cli(monkeypatch) + + cfgfile = tmp_path / "config.yaml" + cfgfile.write_text(yaml_text, encoding="utf-8") + monkeypatch.setattr(config, "_get_config_path", lambda: cfgfile) + + # Point auth store at a non-existent file so it stays empty + auth_path = tmp_path / "auth.json" + monkeypatch.setattr(config, "_get_auth_store_path", lambda: auth_path) + + # Inject provider models if given + if provider_models: + for pid, models in provider_models.items(): + monkeypatch.setitem(config._PROVIDER_MODELS, pid, models) + + config.reload_config() + + +def _teardown_config(): + config.reload_config() + + +# =================================================================== +# get_providers() sort order (api/providers.py) +# =================================================================== + +class TestProviderSortOrder: + """providers returned by get_providers() should respect tier ordering.""" + + def test_active_provider_comes_first(self, tmp_path, monkeypatch): + """The active provider (from config model.provider) is tier-0.""" + _setup_config(tmp_path, monkeypatch, + "model:\n provider: openrouter\n default: test-model\n" + "providers:\n" + " anthropic:\n api_key: sk-test-123\n" + " openai: {}\n" + " openrouter:\n api_key: sk-or-123\n" + ) + + result = providers_mod.get_providers() + prov_ids = [p["id"] for p in result["providers"]] + + assert prov_ids[0] == "openrouter", ( + f"Expected openrouter first (active), got order: {prov_ids}" + ) + _teardown_config() + + def test_custom_provider_before_plain(self, tmp_path, monkeypatch): + """custom:* providers (tier-1) sort before providers with keys (tier-2).""" + _setup_config(tmp_path, monkeypatch, + "model:\n provider: openai\n default: gpt-4\n" + "providers:\n" + " openai: {}\n" + " anthropic:\n api_key: sk-ant-123\n" + "custom_providers:\n" + " - name: MyLocal\n" + " base_url: http://localhost:8080/v1\n" + " api_key: local-key\n" + " models:\n" + " - local-model-a\n" + ) + + result = providers_mod.get_providers() + prov_ids = [p["id"] for p in result["providers"]] + + openai_idx = prov_ids.index("openai") + custom_idx = prov_ids.index("custom:mylocal") + anthropic_idx = prov_ids.index("anthropic") + + assert openai_idx < custom_idx < anthropic_idx, ( + f"Expected openai < custom:mylocal < anthropic, got {prov_ids}" + ) + _teardown_config() + + def test_has_key_provider_before_no_key(self, tmp_path, monkeypatch): + """Providers with keys (tier-2) come before those without (tier-3).""" + _setup_config(tmp_path, monkeypatch, + "model:\n provider: openai\n default: gpt-4\n" + "providers:\n" + " openai: {}\n" + " deepseek:\n api_key: sk-ds-123\n" + " google: {}\n" + " groq: {}\n" + " xai:\n api_key: xai-key\n" + ) + + result = providers_mod.get_providers() + prov_ids = [p["id"] for p in result["providers"]] + + deepseek_idx = prov_ids.index("deepseek") + xai_idx = prov_ids.index("xai") + google_idx = prov_ids.index("google") + groq_idx = prov_ids.index("groq") + + assert deepseek_idx < google_idx, ( + f"deepseek(has_key) at {deepseek_idx} should be before google at {google_idx}" + ) + assert deepseek_idx < groq_idx + assert xai_idx < google_idx + assert xai_idx < groq_idx + _teardown_config() + + +# =================================================================== +# get_available_models() sort order (api/config.py) +# =================================================================== + +class TestModelPickerSortOrder: + """Model picker groups should follow the same tier ordering.""" + + def test_active_provider_group_is_first(self, tmp_path, monkeypatch): + """The group for the active provider appears first in groups list.""" + _setup_config(tmp_path, monkeypatch, + "model:\n provider: anthropic\n default: claude-3-5-sonnet\n" + "providers:\n" + " openai:\n api_key: sk-oai-123\n" + " anthropic: {}\n", + provider_models={ + "openai": _model_list("gpt-4", "gpt-4o"), + "anthropic": _model_list("claude-3-5-sonnet"), + }, + ) + + result = config.get_available_models() + group_ids = [g.get("provider_id") for g in result.get("groups", [])] + + assert group_ids[0] == "anthropic", ( + f"Expected anthropic first (active), got: {group_ids}" + ) + _teardown_config() + + def test_custom_groups_before_configured(self, tmp_path, monkeypatch): + """custom:* groups sort before providers that merely have keys.""" + _setup_config(tmp_path, monkeypatch, + "model:\n provider: openai\n default: gpt-4\n" + "providers:\n" + " openai:\n api_key: sk-oai-123\n" + "custom_providers:\n" + " - name: MyLocal\n" + " base_url: http://localhost:8080/v1\n" + " api_key: local-key\n" + " models:\n" + " - local-a\n", + provider_models={ + "openai": _model_list("gpt-4"), + }, + ) + + result = config.get_available_models() + group_ids = [g.get("provider_id") for g in result.get("groups", [])] + + if "custom:mylocal" in group_ids: + custom_idx = group_ids.index("custom:mylocal") + for i, gid in enumerate(group_ids): + if gid not in ("openai", "custom:mylocal"): + assert custom_idx < i, ( + f"custom:mylocal at {custom_idx} should precede {gid} at {i}" + ) + _teardown_config() + + def test_configured_key_groups_before_no_key(self, tmp_path, monkeypatch): + """Providers with api_key in config sort before those without.""" + _setup_config(tmp_path, monkeypatch, + "model:\n provider: openai\n default: gpt-4\n" + "providers:\n" + " openai:\n api_key: sk-oai-123\n" + " deepseek:\n api_key: sk-ds-123\n" + " google: {}\n", + provider_models={ + "openai": _model_list("gpt-4"), + "deepseek": _model_list("deepseek-chat"), + "google": _model_list("gemini-pro"), + }, + ) + + result = config.get_available_models() + group_ids = [g.get("provider_id") for g in result.get("groups", [])] + + if "deepseek" in group_ids and "google" in group_ids: + ds_idx = group_ids.index("deepseek") + google_idx = group_ids.index("google") + assert ds_idx < google_idx, ( + f"deepseek (has key) at {ds_idx} should precede google (no key) at {google_idx}" + ) + _teardown_config() From 84ef8a63a6a9c27ccfa8c7e68b0bc68b118bde41 Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Thu, 21 May 2026 16:56:16 +0800 Subject: [PATCH 4/5] fix: remove xai from has_key test (CI env has no XAI_API_KEY) --- tests/test_provider_sort_order.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_provider_sort_order.py b/tests/test_provider_sort_order.py index 6bb04dc9..8dd1658a 100644 --- a/tests/test_provider_sort_order.py +++ b/tests/test_provider_sort_order.py @@ -150,23 +150,22 @@ class TestProviderSortOrder: " deepseek:\n api_key: sk-ds-123\n" " google: {}\n" " groq: {}\n" - " xai:\n api_key: xai-key\n" ) result = providers_mod.get_providers() prov_ids = [p["id"] for p in result["providers"]] + # deepseek has api_key in config → should be tier-2 (before tier-3) deepseek_idx = prov_ids.index("deepseek") - xai_idx = prov_ids.index("xai") google_idx = prov_ids.index("google") groq_idx = prov_ids.index("groq") assert deepseek_idx < google_idx, ( f"deepseek(has_key) at {deepseek_idx} should be before google at {google_idx}" ) - assert deepseek_idx < groq_idx - assert xai_idx < google_idx - assert xai_idx < groq_idx + assert deepseek_idx < groq_idx, ( + f"deepseek(has_key) at {deepseek_idx} should be before groq at {groq_idx}" + ) _teardown_config() From 4da2a8e21ac5d2e99d4775e8c6e0fbb357b840be Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Fri, 22 May 2026 16:20:41 +0000 Subject: [PATCH 5/5] Stamp CHANGELOG for v0.51.110 (Release CH / stage-403 / 2-PR batch) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 664aa7fe..0f4b1b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.110] — 2026-05-22 — Release CH (stage-403 — 2-PR batch — default personality from config + sort configured providers to top) + +### Added + +- **PR #2747** by @s010mn — `new_session()` now reads `display.personality` from `config.yaml` as the default for new conversations. Previously every new session started with `personality=None` and required an explicit `/personality ` slash command. Values `'none'`, `'default'`, `'neutral'`, and empty string are treated as no-personality. Case-insensitive — `personality: Taleb` normalizes to `taleb`. Config-read is wrapped in try/except so malformed config falls back to the prior behavior rather than crashing session creation. The `/personality` slash command still works for per-session overrides. +- **PR #2683** by @jasonjcwu — Sort providers so configured/custom entries appear first in both the model picker dropdown (`api/config.py::get_available_models`) and the Settings providers panel (`api/providers.py::get_providers`). Priority order: (1) the active provider, (2) `custom:*` providers from `custom_providers` config, (3) providers with configured API keys (credential pool or `config.yaml`), (4) all others alphabetical. Eliminates scrolling past 25+ unconfigured providers to find the one in active use. + ## [v0.51.109] — 2026-05-22 — Release CG (stage-402 — 2-PR batch — sidebar action menu click stability + chat panel sidebar resync after navigation) ### Fixed