fix(tui): guard personality overlay when personalities is null

TUI auto-resolves `display.personality` at session init, unlike the base CLI.
If config contains `agent.personalities: null`, `_resolve_personality_prompt`
called `.get()` on None and failed before model/provider selection.
Normalize null personalities to `{}` and surface a targeted config warning.
This commit is contained in:
Brooklyn Nicholson 2026-04-24 12:57:51 -05:00
parent bfa60234c8
commit e3940f9807
2 changed files with 77 additions and 12 deletions

View file

@ -67,11 +67,25 @@ def test_probe_config_health_flags_null_sections():
assert "model" not in msg assert "model" not in msg
def test_probe_config_health_flags_null_personalities_with_active_personality():
from tui_gateway.server import _probe_config_health
msg = _probe_config_health(
{
"agent": {"personalities": None},
"display": {"personality": "kawaii"},
"model": {},
}
)
assert "display.personality" in msg
assert "agent.personalities" in msg
def test_make_agent_tolerates_null_config_sections(): def test_make_agent_tolerates_null_config_sections():
"""Bare `agent:` / `display:` keys in ~/.hermes/config.yaml parse as """Bare `agent:` / `display:` keys in ~/.hermes/config.yaml parse as
None. cfg.get("agent", {}) returns None (default only fires on missing None. cfg.get("agent", {}) returns None (default only fires on missing
key), so downstream .get() chains must be guarded. Reported via Twitter key), so downstream .get() chains must be guarded. Reported via Twitter
against the new TUI; CLI path is unaffected.""" against the new TUI."""
fake_runtime = { fake_runtime = {
"provider": "openrouter", "provider": "openrouter",
@ -99,3 +113,37 @@ def test_make_agent_tolerates_null_config_sections():
_make_agent("sid-null", "key-null") _make_agent("sid-null", "key-null")
assert mock_agent.called assert mock_agent.called
def test_make_agent_tolerates_null_personalities_with_active_personality():
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
cfg = {
"agent": {"personalities": None},
"display": {"personality": "kawaii"},
"model": {"default": "glm-5"},
}
with (
patch("tui_gateway.server._load_cfg", return_value=cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("cli.load_cli_config", return_value={"agent": {"personalities": None}}),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-null-personality", "key-null-personality")
assert mock_agent.called
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None

View file

@ -829,15 +829,32 @@ def _probe_config_health(cfg: dict) -> str:
drop nested settings. Returns warning or ''.""" drop nested settings. Returns warning or ''."""
if not isinstance(cfg, dict): if not isinstance(cfg, dict):
return "" return ""
warnings: list[str] = []
null_keys = sorted(k for k, v in cfg.items() if v is None) null_keys = sorted(k for k, v in cfg.items() if v is None)
if not null_keys: if not null_keys:
return "" pass
keys = ", ".join(f"`{k}`" for k in null_keys) else:
return ( keys = ", ".join(f"`{k}`" for k in null_keys)
f"config.yaml has empty section(s): {keys}. " warnings.append(
f"Remove the line(s) or set them to `{{}}` — " f"config.yaml has empty section(s): {keys}. "
f"empty sections silently drop nested settings." f"Remove the line(s) or set them to `{{}}` — "
) f"empty sections silently drop nested settings."
)
display_cfg = cfg.get("display")
agent_cfg = cfg.get("agent")
if isinstance(display_cfg, dict):
personality = str(display_cfg.get("personality", "") or "").strip().lower()
if (
personality
and personality not in {"default", "none", "neutral"}
and isinstance(agent_cfg, dict)
and agent_cfg.get("personalities") is None
):
warnings.append(
"`display.personality` is set but `agent.personalities` is empty/null; "
"personality overlay will be skipped."
)
return " ".join(warnings).strip()
def _session_info(agent) -> dict: def _session_info(agent) -> dict:
@ -1134,16 +1151,16 @@ def _resolve_personality_prompt(cfg: dict) -> str:
try: try:
from cli import load_cli_config from cli import load_cli_config
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
except Exception: except Exception:
try: try:
from hermes_cli.config import load_config as _load_full_cfg from hermes_cli.config import load_config as _load_full_cfg
personalities = (_load_full_cfg().get("agent") or {}).get( personalities = (
"personalities", {} (_load_full_cfg().get("agent") or {}).get("personalities", {}) or {}
) )
except Exception: except Exception:
personalities = (cfg.get("agent") or {}).get("personalities", {}) personalities = (cfg.get("agent") or {}).get("personalities", {}) or {}
pval = personalities.get(name) pval = personalities.get(name)
if pval is None: if pval is None:
return "" return ""