Files
hermes-agent/tests/hermes_cli/test_personality_single_owner.py
T
Teknium 244d296646 fix(personality): single-owner personality state + one-time reset migration
Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.

- hermes_cli/personality.py: new single owner of personality state.
  Built-in personality definitions, neutral-name normalization, rendering,
  availability (built-ins overlaid by agent.personalities), overlay
  resolution, and the ONLY sanctioned persistence path
  (persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
  (announcing which personality was cleared and how to re-enable), plus a
  scrub of agent.system_prompt when it verbatim-equals a known personality
  render (machine-written by the old CLI/gateway). Hand-written manual
  prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
  marker in the list), gateway /personality, TUI config.set + slash path
  (which previously applied without persisting), TUI config.get (reports
  the EFFECTIVE personality), completer, hermes config display, and the
  tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
  desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
  available, one-time reset note.
2026-08-09 10:33:58 -07:00

224 lines
7.8 KiB
Python

"""Tests for hermes_cli.personality — the single owner of personality state —
and the v34 one-time personality reset migration.
Regression coverage for the post-#81946 resurrection bug: personality state
used to be persisted differently per surface (TUI/desktop wrote the NAME to
display.personality, CLI/gateway wrote rendered TEXT to agent.system_prompt),
so making display.personality authoritative resurrected personalities users
had already turned off ("kawaii defaults on after updating").
"""
import os
from unittest.mock import patch
import pytest
import yaml
from hermes_cli.personality import (
BUILTIN_PERSONALITIES,
available_personalities,
active_personality_name,
describe_personality,
normalize_personality_name,
persist_personality,
prompt_text,
render_personality_prompt,
resolve_ephemeral_system_prompt,
resolve_personality,
)
KAWAII = BUILTIN_PERSONALITIES["kawaii"]
# ── module semantics ──────────────────────────────────────────────────────────
def test_builtins_available_without_any_config():
merged = available_personalities(None)
assert len(merged) >= 1
for name in merged:
assert name == name.lower()
# built-ins render to non-empty prompts
for defn in merged.values():
assert render_personality_prompt(defn)
def test_user_entries_overlay_builtins_by_name():
cfg = {"agent": {"personalities": {"kawaii": "toned down", "custom": "hi"}}}
merged = available_personalities(cfg)
assert merged["kawaii"] == "toned down"
assert merged["custom"] == "hi"
def test_neutral_names_normalize_to_empty():
for raw in ("", "none", "None", " DEFAULT ", "neutral", None):
assert normalize_personality_name(raw) == ""
def test_resolve_personality_neutral_and_case_insensitive():
assert resolve_personality("none", {}) == ("", "")
name, prompt = resolve_personality(" KAWAII ", {})
assert name == "kawaii"
assert prompt == KAWAII
def test_resolve_personality_unknown_raises_with_listing():
with pytest.raises(ValueError) as exc:
resolve_personality("doesnotexist", {})
assert "Available" in str(exc.value)
assert "`none`" in str(exc.value)
def test_resolve_overlay_personality_wins_over_manual_prompt():
cfg = {
"display": {"personality": "kawaii"},
"agent": {"system_prompt": "manual forever"},
}
assert resolve_ephemeral_system_prompt(cfg) == KAWAII
def test_resolve_overlay_falls_back_to_manual_prompt():
for neutral in ("", "none", "default", "neutral"):
cfg = {
"display": {"personality": neutral},
"agent": {"system_prompt": "manual forever"},
}
assert resolve_ephemeral_system_prompt(cfg) == "manual forever"
def test_resolve_overlay_ignores_unknown_name():
cfg = {
"display": {"personality": "ghost"},
"agent": {"system_prompt": "manual forever"},
}
assert resolve_ephemeral_system_prompt(cfg) == "manual forever"
assert active_personality_name(cfg) == ""
def test_render_dict_personality():
rendered = render_personality_prompt(
{"system_prompt": "You are X.", "tone": "warm", "style": "brief"}
)
assert "You are X." in rendered
assert "Tone: warm" in rendered
assert "Style: brief" in rendered
def test_prompt_text_normalizes_none_str_list():
assert prompt_text(None) == ""
assert prompt_text(" hi ") == "hi"
assert prompt_text(["a", " b ", ""]) == "a\nb"
def test_describe_personality_truncates_and_flattens():
assert describe_personality("x" * 80) == "x" * 50 + "..."
assert "\n" not in describe_personality("a\nb")
assert describe_personality({"description": "short desc"}) == "short desc"
# ── persistence (single write path) ──────────────────────────────────────────
def test_persist_personality_roundtrip(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
assert persist_personality("KAWAII ") is True
raw = yaml.safe_load((home / "config.yaml").read_text())
assert raw["display"]["personality"] == "kawaii"
assert persist_personality("none") is True
raw = yaml.safe_load((home / "config.yaml").read_text())
assert raw["display"]["personality"] == ""
def test_persist_personality_never_touches_system_prompt(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
yaml.safe_dump({"agent": {"system_prompt": "manual forever"}})
)
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
assert persist_personality("kawaii") is True
raw = yaml.safe_load((home / "config.yaml").read_text())
assert raw["agent"]["system_prompt"] == "manual forever"
assert raw["display"]["personality"] == "kawaii"
# ── v34 migration: one-time reset of stale split-brain state ─────────────────
def _run_migration(home, cfg):
(home / "config.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True))
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
from hermes_cli.config import migrate_config, read_raw_config
results = migrate_config(interactive=False, quiet=True)
return read_raw_config(), results
def test_migration_resets_stale_personality_name(tmp_path):
# Shape 1: TUI/desktop wrote the name years ago; the old CLI/gateway
# "/personality none" never cleared it. Post-#81946 it resurrected.
home = tmp_path / ".hermes"
home.mkdir()
raw, results = _run_migration(
home,
{"_config_version": 33, "display": {"personality": "kawaii"}},
)
assert raw["display"]["personality"] == ""
assert resolve_ephemeral_system_prompt(raw) == ""
assert any("personality" in item for item in results["config_added"])
def test_migration_scrubs_personality_text_from_system_prompt(tmp_path):
# Shape 2: old CLI/gateway wrote rendered personality TEXT into
# agent.system_prompt. Verbatim match with a known personality render
# proves machine-written — scrub it.
home = tmp_path / ".hermes"
home.mkdir()
raw, _ = _run_migration(
home,
{"_config_version": 33, "agent": {"system_prompt": KAWAII}},
)
assert raw["agent"]["system_prompt"] == ""
assert resolve_ephemeral_system_prompt(raw) == ""
def test_migration_preserves_manual_system_prompt(tmp_path):
# Shape 3: a hand-written prompt never verbatim-matches a personality
# render — it must survive untouched while the stale name is reset.
home = tmp_path / ".hermes"
home.mkdir()
raw, _ = _run_migration(
home,
{
"_config_version": 33,
"display": {"personality": "pirate"},
"agent": {"system_prompt": "my manual prompt"},
},
)
assert raw["display"]["personality"] == ""
assert raw["agent"]["system_prompt"] == "my manual prompt"
assert resolve_ephemeral_system_prompt(raw) == "my manual prompt"
def test_migration_noop_when_nothing_stale(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
raw, results = _run_migration(home, {"_config_version": 33})
assert not any("personality" in item for item in results["config_added"])
def test_post_v34_choice_is_never_reset(tmp_path):
# The reset fires exactly once (33→34). A personality chosen AFTER the
# migration is the user's real selection and must survive later runs.
home = tmp_path / ".hermes"
home.mkdir()
raw, _ = _run_migration(
home,
{"_config_version": 34, "display": {"personality": "kawaii"}},
)
assert raw["display"]["personality"] == "kawaii"
assert resolve_ephemeral_system_prompt(raw) == KAWAII