163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
"""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}"
|
|
)
|