From da6f0030aba46d66a0edb2fec35797123db6b6de Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 14:01:56 -0500 Subject: [PATCH 1/3] feat(config): resolve ephemeral prompt from display.personality Keep agent.system_prompt user-owned; named personalities resolve as an ephemeral overlay via display.personality. Co-authored-by: kyssta-exe Co-authored-by: EMT5320 <1908937833@qq.com> --- hermes_cli/config.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e9aece05fb9bc..199b3360d1662 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2929,6 +2929,49 @@ def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> A return node +_NEUTRAL_PERSONALITY_NAMES = frozenset({"", "none", "default", "neutral"}) + + +def _prompt_text(value: Any) -> str: + """Normalize config prompt values from YAML before handing them to AIAgent.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + return "\n".join(str(item).strip() for item in value if str(item).strip()) + return str(value).strip() + + +def render_personality_prompt(value: Any) -> str: + """Render a string or structured personality definition to a prompt.""" + if isinstance(value, dict): + parts = [value.get("system_prompt", "")] + if value.get("tone"): + parts.append(f'Tone: {value["tone"]}') + if value.get("style"): + parts.append(f'Style: {value["style"]}') + return "\n".join(str(part).strip() for part in parts if str(part).strip()) + return _prompt_text(value) + + +def resolve_ephemeral_system_prompt_from_config(cfg: Optional[Dict[str, Any]]) -> str: + """Resolve the session overlay from config.yaml. + + ``display.personality`` is the selected named personality and wins when set. + Otherwise fall back to the user-owned ``agent.system_prompt``. Callers should + still prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` when that env var is set. + """ + name = str(cfg_get(cfg, "display", "personality", default="") or "").strip().lower() + personalities = cfg_get(cfg, "agent", "personalities", default={}) or {} + if ( + name not in _NEUTRAL_PERSONALITY_NAMES + and isinstance(personalities, dict) + and name in personalities + ): + return render_personality_prompt(personalities[name]) + return _prompt_text(cfg_get(cfg, "agent", "system_prompt", default="")) + def read_raw_config() -> Dict[str, Any]: """Read ~/.hermes/config.yaml as-is, without merging defaults or migrating. From a0d406dcd8a78cc466dcc99976f2f1b9cb48e983 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 14:01:56 -0500 Subject: [PATCH 2/3] fix(personality): stop writing personality into agent.system_prompt Persist display.personality only; apply rendered text as an in-session overlay across CLI, TUI config.set, and gateway /personality. Co-authored-by: kyssta-exe Co-authored-by: EMT5320 <1908937833@qq.com> --- cli.py | 7 +++++-- gateway/run.py | 10 +++++---- gateway/slash_commands.py | 36 ++++++++++++++++---------------- hermes_cli/cli_commands_mixin.py | 23 +++++++++++++++----- tui_gateway/server.py | 9 +++++--- 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/cli.py b/cli.py index 2decc0e204e6a..ce7ca7e30f6fa 100644 --- a/cli.py +++ b/cli.py @@ -4502,10 +4502,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # AGENTS.md/SOUL.md/.cursorrules and persistent memory are not loaded. self.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1" - # Ephemeral system prompt: env var takes precedence, then config + # Ephemeral system prompt: env var takes precedence, then + # display.personality / agent.system_prompt from config. + from hermes_cli.config import resolve_ephemeral_system_prompt_from_config + self.system_prompt = ( os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") - or CLI_CONFIG["agent"].get("system_prompt", "") + or resolve_ephemeral_system_prompt_from_config(CLI_CONFIG) ) self.personalities = CLI_CONFIG["agent"].get("personalities", {}) diff --git a/gateway/run.py b/gateway/run.py index 8160038b44956..55f6b1305b3d4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8170,15 +8170,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew @staticmethod def _load_ephemeral_system_prompt() -> str: """Load ephemeral system prompt from config or env var. - - Checks HERMES_EPHEMERAL_SYSTEM_PROMPT env var first, then falls back to - agent.system_prompt in ~/.hermes/config.yaml. + + Checks HERMES_EPHEMERAL_SYSTEM_PROMPT env var first, then + ``display.personality`` / ``agent.system_prompt`` in config.yaml. """ + from hermes_cli.config import resolve_ephemeral_system_prompt_from_config + prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") if prompt: return prompt cfg = _load_gateway_runtime_config() - return str(cfg_get(cfg, "agent", "system_prompt", default="") or "").strip() + return resolve_ephemeral_system_prompt_from_config(cfg) def _resolve_model_for_channel( self, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 8675f317f8b5a..7205516a04485 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2519,34 +2519,34 @@ class GatewaySlashCommandsMixin: lines.append(t("gateway.personality.usage")) return "\n".join(lines) - def _resolve_prompt(value): - if isinstance(value, dict): - parts = [value.get("system_prompt", "")] - if value.get("tone"): - parts.append(f'Tone: {value["tone"]}') - if value.get("style"): - parts.append(f'Style: {value["style"]}') - return "\n".join(p for p in parts if p) - return str(value) + from hermes_cli.config import ( + _prompt_text, + render_personality_prompt, + ) if args in {"none", "default", "neutral"}: + # Persist the selection only. Never clear agent.system_prompt — + # that field is the user-owned manual overlay. try: - if "agent" not in config or not isinstance(config.get("agent"), dict): - config["agent"] = {} - config["agent"]["system_prompt"] = "" + if "display" not in config or not isinstance(config.get("display"), dict): + config["display"] = {} + config["display"]["personality"] = "" atomic_config_write(config_path, config) except Exception as e: return t("gateway.personality.save_failed", error=str(e)) - self._ephemeral_system_prompt = "" + self._ephemeral_system_prompt = _prompt_text( + cfg_get(config, "agent", "system_prompt", default="") + ) return t("gateway.personality.cleared") elif args in personalities: - new_prompt = _resolve_prompt(personalities[args]) + new_prompt = render_personality_prompt(personalities[args]) - # Write to config.yaml, same pattern as CLI save_config_value. + # Persist the personality name only — never write personality text + # into agent.system_prompt (user-owned manual overlay). try: - if "agent" not in config or not isinstance(config.get("agent"), dict): - config["agent"] = {} - config["agent"]["system_prompt"] = new_prompt + if "display" not in config or not isinstance(config.get("display"), dict): + config["display"] = {} + config["display"]["personality"] = args atomic_config_write(config_path, config) except Exception as e: return t("gateway.personality.save_failed", error=str(e)) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index bb5e67874f712..889fb02a4c96e 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1340,21 +1340,34 @@ class CLICommandsMixin: personality_name = parts[1].strip().lower() if personality_name in {"none", "default", "neutral"}: - self.system_prompt = "" + # Persist the selection only. Never clear agent.system_prompt — + # that field is the user-owned manual overlay. + saved = save_config_value("display.personality", "") + try: + from hermes_cli.config import cfg_get, read_raw_config, _prompt_text + + self.system_prompt = _prompt_text( + cfg_get(read_raw_config(), "agent", "system_prompt", default="") + ) + except Exception: + self.system_prompt = "" self.agent = None # Force re-init - if save_config_value("agent.system_prompt", ""): + if saved: print("(^_^)b Personality cleared (saved to config)") else: print("(^_^) Personality cleared (session only)") print(" No personality overlay — using base agent behavior.") elif personality_name in self.personalities: - self.system_prompt = self._resolve_personality_prompt(self.personalities[personality_name]) + personality_prompt = self._resolve_personality_prompt( + self.personalities[personality_name] + ) + self.system_prompt = personality_prompt self.agent = None # Force re-init - if save_config_value("agent.system_prompt", self.system_prompt): + if save_config_value("display.personality", personality_name): print(f"(^_^)b Personality set to '{personality_name}' (saved to config)") else: print(f"(^_^) Personality set to '{personality_name}' (session only)") - print(f" \"{self.system_prompt[:60]}{'...' if len(self.system_prompt) > 60 else ''}\"") + print(f" \"{personality_prompt[:60]}{'...' if len(personality_prompt) > 60 else ''}\"") else: print(f"(._.) Unknown personality: {personality_name}") print(f" Available: none, {', '.join(self.personalities.keys())}") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 67083a69269f6..4b58252b34d30 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6471,8 +6471,9 @@ def _make_agent( pass cfg = _load_cfg() - agent_cfg = cfg.get("agent") or {} - system_prompt = _prompt_text(agent_cfg.get("system_prompt", "")) + from hermes_cli.config import resolve_ephemeral_system_prompt_from_config + + system_prompt = resolve_ephemeral_system_prompt_from_config(cfg) startup_skills = _parse_tui_skills_env() if startup_skills: from agent.skill_commands import build_preloaded_skills_prompt @@ -11336,8 +11337,10 @@ def _(rid, params: dict) -> dict: elif key == "personality": sid_key = params.get("session_id", "") pname, new_prompt = _validate_personality(str(value or ""), cfg) + # Personality text is an in-session overlay. Keep the + # user-owned global system prompt intact so changing a + # personality cannot destroy manual configuration. _write_config_key("display.personality", pname) - _write_config_key("agent.system_prompt", new_prompt) nv = str(value or "none") history_reset, info = _apply_personality_to_session( sid_key, session, new_prompt, pname From fe9e4d177696dd42538dc4979ef4ada72dbf4850 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 14:01:57 -0500 Subject: [PATCH 3/3] test(personality): regression coverage for #81791 Assert config.set and /personality preserve manual agent.system_prompt, and that startup resolution prefers display.personality. Co-authored-by: kyssta-exe Co-authored-by: EMT5320 <1908937833@qq.com> --- tests/cli/test_personality_none.py | 71 +++++++- .../test_resolve_ephemeral_system_prompt.py | 63 +++++++ tests/test_tui_gateway_server.py | 9 +- .../test_make_agent_personality_prompt.py | 73 ++++++++ ...test_personality_clobbers_system_prompt.py | 162 ++++++++++++++++++ 5 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 tests/hermes_cli/test_resolve_ephemeral_system_prompt.py create mode 100644 tests/tui_gateway/test_make_agent_personality_prompt.py create mode 100644 tests/tui_gateway/test_personality_clobbers_system_prompt.py diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index 9582628516fc0..f7a05fc35fe63 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -22,11 +22,41 @@ class TestCLIPersonalityNone: - def test_neutral_clears_system_prompt(self): + def test_set_persists_display_personality_not_system_prompt(self): cli = self._make_cli() - with patch("cli.save_config_value", return_value=True): + saves = [] + + def _save(key, value): + saves.append((key, value)) + return True + + with patch("cli.save_config_value", side_effect=_save): + cli._handle_personality_command("/personality helpful") + + assert cli.system_prompt == "You are helpful." + assert ("display.personality", "helpful") in saves + assert not any(k == "agent.system_prompt" for k, _ in saves) + + def test_neutral_restores_manual_system_prompt_without_wiping_config(self): + cli = self._make_cli() + saves = [] + + def _save(key, value): + saves.append((key, value)) + return True + + with ( + patch("cli.save_config_value", side_effect=_save), + patch( + "hermes_cli.config.read_raw_config", + return_value={"agent": {"system_prompt": "manual forever"}}, + ), + ): cli._handle_personality_command("/personality neutral") - assert cli.system_prompt == "" + + assert cli.system_prompt == "manual forever" + assert ("display.personality", "") in saves + assert not any(k == "agent.system_prompt" for k, _ in saves) @@ -59,7 +89,13 @@ class TestGatewayPersonalityNone: @pytest.mark.asyncio async def test_default_clears_ephemeral_prompt(self, tmp_path): runner = self._make_runner() - config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}} + config_data = { + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + }, + "display": {"personality": "helpful"}, + } config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump(config_data)) @@ -67,7 +103,32 @@ class TestGatewayPersonalityNone: event = self._make_event("default") result = await runner._handle_personality_command(event) - assert runner._ephemeral_system_prompt == "" + saved = yaml.safe_load(config_file.read_text()) + assert saved["agent"]["system_prompt"] == "manual forever" + assert saved.get("display", {}).get("personality", None) == "" + assert runner._ephemeral_system_prompt == "manual forever" + + @pytest.mark.asyncio + async def test_set_persists_display_personality_not_system_prompt(self, tmp_path): + runner = self._make_runner() + config_data = { + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + } + } + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump(config_data)) + + with patch("gateway.run._hermes_home", tmp_path): + event = self._make_event("helpful") + result = await runner._handle_personality_command(event) + + saved = yaml.safe_load(config_file.read_text()) + assert saved["agent"]["system_prompt"] == "manual forever" + assert saved["display"]["personality"] == "helpful" + assert runner._ephemeral_system_prompt == "You are helpful." + assert "helpful" in result.lower() @pytest.mark.asyncio diff --git a/tests/hermes_cli/test_resolve_ephemeral_system_prompt.py b/tests/hermes_cli/test_resolve_ephemeral_system_prompt.py new file mode 100644 index 0000000000000..6a1ac5f326f1c --- /dev/null +++ b/tests/hermes_cli/test_resolve_ephemeral_system_prompt.py @@ -0,0 +1,63 @@ +"""Unit tests for resolve_ephemeral_system_prompt_from_config.""" + +from hermes_cli.config import ( + render_personality_prompt, + resolve_ephemeral_system_prompt_from_config, +) + + +def test_resolve_uses_named_personality_when_set(): + cfg = { + "display": {"personality": "helpful"}, + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + }, + } + assert resolve_ephemeral_system_prompt_from_config(cfg) == "You are helpful." + + +def test_resolve_falls_back_to_manual_system_prompt(): + cfg = { + "display": {"personality": "none"}, + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + }, + } + assert resolve_ephemeral_system_prompt_from_config(cfg) == "manual forever" + + +def test_resolve_ignores_unknown_personality_name(): + cfg = { + "display": {"personality": "missing"}, + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + }, + } + assert resolve_ephemeral_system_prompt_from_config(cfg) == "manual forever" + + +def test_resolve_renders_dict_personality(): + cfg = { + "display": {"personality": "coder"}, + "agent": { + "system_prompt": "manual forever", + "personalities": { + "coder": { + "system_prompt": "You are an expert programmer.", + "tone": "technical", + "style": "concise", + } + }, + }, + } + resolved = resolve_ephemeral_system_prompt_from_config(cfg) + assert "You are an expert programmer." in resolved + assert "Tone: technical" in resolved + assert "Style: concise" in resolved + + +def test_render_personality_prompt_string(): + assert render_personality_prompt(" hi ") == "hi" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 8a64537c9365d..73d0f986bb945 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7439,6 +7439,7 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch): history_version=4, ) emits = [] + writes = [] server._sessions["sid"] = session monkeypatch.setattr( @@ -7450,7 +7451,11 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch): server, "_session_info", lambda agent, *a: {"model": getattr(agent, "model", "?")} ) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) - monkeypatch.setattr(server, "_write_config_key", lambda path, value: None) + monkeypatch.setattr( + server, + "_write_config_key", + lambda path, value: writes.append((path, value)), + ) resp = server.handle_request( { @@ -7473,6 +7478,8 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch): assert agent.ephemeral_system_prompt == "You are helpful." assert agent._cached_system_prompt == "old" assert ("session.info", "sid", {"model": "?"}) in emits + assert ("display.personality", "helpful") in writes + assert not any(path == "agent.system_prompt" for path, _ in writes) def test_compress_session_history_passes_force(): diff --git a/tests/tui_gateway/test_make_agent_personality_prompt.py b/tests/tui_gateway/test_make_agent_personality_prompt.py new file mode 100644 index 0000000000000..cd55f8876cecf --- /dev/null +++ b/tests/tui_gateway/test_make_agent_personality_prompt.py @@ -0,0 +1,73 @@ +"""_make_agent resolves ephemeral prompt from display.personality.""" + +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + + +def _runtime(): + return { + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + "command": None, + "args": None, + "credential_pool": None, + } + + +def _call_make_agent(cfg): + with ExitStack() as stack: + stack.enter_context(patch("tui_gateway.server._load_cfg", return_value=cfg)) + stack.enter_context(patch("tui_gateway.server._get_db", return_value=MagicMock())) + stack.enter_context( + patch("tui_gateway.server._load_tool_progress_mode", return_value="compact") + ) + stack.enter_context( + patch("tui_gateway.server._load_reasoning_config", return_value=None) + ) + stack.enter_context(patch("tui_gateway.server._load_service_tier", return_value=None)) + stack.enter_context( + patch("tui_gateway.server._load_enabled_toolsets", return_value=None) + ) + stack.enter_context( + patch( + "tui_gateway.server._resolve_startup_runtime", + return_value=("test-model", None), + ) + ) + stack.enter_context( + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=_runtime(), + ) + ) + mock_agent = stack.enter_context(patch("run_agent.AIAgent")) + from tui_gateway.server import _make_agent + + _make_agent("sid-1", "key-1") + return mock_agent.call_args.kwargs + + +def test_make_agent_uses_display_personality_when_set(): + cfg = { + "display": {"personality": "helpful"}, + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + }, + } + kwargs = _call_make_agent(cfg) + assert kwargs["ephemeral_system_prompt"] == "You are helpful." + + +def test_make_agent_preserves_manual_prompt_without_personality(): + cfg = { + "display": {"personality": "none"}, + "agent": { + "system_prompt": "manual forever", + "personalities": {"helpful": "You are helpful."}, + }, + } + kwargs = _call_make_agent(cfg) + assert kwargs["ephemeral_system_prompt"] == "manual forever" diff --git a/tests/tui_gateway/test_personality_clobbers_system_prompt.py b/tests/tui_gateway/test_personality_clobbers_system_prompt.py new file mode 100644 index 0000000000000..36a99c1167d52 --- /dev/null +++ b/tests/tui_gateway/test_personality_clobbers_system_prompt.py @@ -0,0 +1,162 @@ +"""Reproduction: /personality (desktop config.set) clobbers agent.system_prompt. + +The desktop/TUI backend exposes a `config.set key=personality` RPC +(tui_gateway/server.py, the `@method("config.set")` handler). When the user +selects a personality (the desktop "assistant style" picker routes here), the +handler does: + + _write_config_key("display.personality", pname) + _write_config_key("agent.system_prompt", new_prompt) # <-- clobbers manual text + +`agent.system_prompt` is the user's GLOBAL manual system prompt (read by +`/prompt` and the CLI's ephemeral-prompt path). Overwriting it with a +personality's text destroys the manual prompt. The sibling in-chat slash path +(server.py `/personality` handler) does NOT touch `agent.system_prompt` -- it +only sets the in-session `agent.ephemeral_system_prompt` -- so the two paths +disagree. + +Contract tested here: +1. A pre-existing manual `agent.system_prompt` must survive selecting a + personality via `config.set key=personality` (the global manual prompt is + a SEPARATE concept from the per-session personality overlay). +2. Selecting a different personality must not leave a stale personality's + text in `agent.system_prompt`. + +This test drives the REAL `_write_config_key` / `_save_cfg` against a temp +HERMES_HOME config.yaml (no mocks of the persistence layer), so the captured +config file is genuine proof of the write. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import tui_gateway.server as server +import yaml + + +MANUAL_PROMPT = "manual_prompt_1" +PERSONALITY_1 = "personality_1" +PERSONALITY_2 = "personality_2" + + +def _seed_config(home: str) -> None: + cfg = { + "agent": { + "system_prompt": MANUAL_PROMPT, + "personalities": { + "personality_1": PERSONALITY_1, + "personality_2": PERSONALITY_2, + }, + }, + "display": {"personality": "none"}, + } + with open(os.path.join(home, "config.yaml"), "w", encoding="utf-8") as f: + yaml.safe_dump(cfg, f) + + +def _read_saved_system_prompt(home: str) -> str: + with open(os.path.join(home, "config.yaml"), encoding="utf-8") as f: + data = yaml.safe_load(f) + return (data.get("agent", {}) or {}).get("system_prompt", "") + + +def _set(params: dict) -> dict: + return server._methods["config.set"]("rid-1", params) + + +def _make_session(session_id: str): + """Mirror a live desktop session: real agent namespace + history buffer.""" + agent = MagicMock() + agent.ephemeral_system_prompt = None + return { + "session_key": session_id, + "agent": agent, + "history": [], + "history_version": 0, + "history_lock": MagicMock(__enter__=lambda self: None, __exit__=lambda self, *a: None), + } + + +def _run(home: str, session_id: str = ""): + """Select `personality_1` via the desktop personality RPC. + + We stub the live-session pivot (_apply_personality_to_session) because the + bug under test is the *config write* at server.py:11328-11329, not the + in-session marker injection. Keeping it real would require a fully-built + agent; the write happens before it regardless. + """ + sid = session_id or "s1" + session = _make_session(sid) + with ( + patch.dict( + server._sessions, {sid: session}, clear=False + ), + patch.object(server, "_apply_personality_to_session", return_value=(False, None)), + # The handler calls these for the live-session pivot; not under test. + patch.object(server, "_persist_live_session_runtime"), + patch.object(server, "_emit"), + ): + resp = _set({"key": "personality", "value": "personality_1", "session_id": sid}) + return resp + + +def test_personality_selection_preserves_manual_system_prompt(tmp_path, monkeypatch): + """Selecting a personality must NOT overwrite the manual agent.system_prompt.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # Force the server module to pick up the temp home for config reads/writes. + monkeypatch.setattr(server, "_hermes_home", Path(tmp_path)) + monkeypatch.setattr(server, "_cfg_path", None) + monkeypatch.setattr(server, "_cfg_cache", None) + _seed_config(str(tmp_path)) + + resp = _run(str(tmp_path)) + + assert resp["result"]["value"] == "personality_1", resp + # THE BUG: the manual system_prompt was overwritten by the personality. + saved = _read_saved_system_prompt(str(tmp_path)) + assert saved == MANUAL_PROMPT, ( + "agent.system_prompt was clobbered by the personality selection.\n" + f" expected (manual): {MANUAL_PROMPT!r}\n" + f" got (personality): {saved!r}" + ) + + +def test_switching_personality_leaves_no_stale_text(tmp_path, monkeypatch): + """After choosing `personality_2`, agent.system_prompt must retain the manual prompt. + + The bug: selecting any personality overwrites agent.system_prompt with that + personality's text. So after `personality_1` then `personality_2`, the field + holds `personality_2`'s text -- still NOT the user's manual prompt. The field + should have been left untouched (the personality overlay belongs in the + in-session ephemeral prompt, not the durable global system prompt). + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(server, "_hermes_home", Path(tmp_path)) + monkeypatch.setattr(server, "_cfg_path", None) + monkeypatch.setattr(server, "_cfg_cache", None) + _seed_config(str(tmp_path)) + + _run(str(tmp_path), session_id="a") + # Now switch to a different personality. + session = _make_session("a") + with ( + patch.dict( + server._sessions, {"a": session}, clear=False + ), + patch.object(server, "_apply_personality_to_session", return_value=(False, None)), + patch.object(server, "_persist_live_session_runtime"), + patch.object(server, "_emit"), + ): + resp = _set({"key": "personality", "value": "personality_2", "session_id": "a"}) + + assert resp["result"]["value"] == "personality_2", resp + saved = _read_saved_system_prompt(str(tmp_path)) + assert saved == MANUAL_PROMPT, ( + "agent.system_prompt still holds a personality's text after switching " + f"personalities; the manual prompt was lost.\n" + f" expected (manual): {MANUAL_PROMPT!r}\n" + f" got: {saved!r}" + )