The previous implementation read SOUL.md files from a filesystem directory. The Hermes agent uses config.yaml agent.personalities section with string or dict format (system_prompt, tone, style), resolved via _resolve_personality_prompt() and passed to AIAgent via ephemeral_system_prompt. Changes: - /api/personalities: reads from config.yaml agent.personalities, not filesystem SOUL.md directories. Calls reload_config() to pick up config changes without restart. - /api/personality/set: resolves prompt from config.yaml using the same logic as hermes-agent cli.py (string or dict with system_prompt/tone/style) - streaming.py: passes personality via agent.ephemeral_system_prompt (agent's own mechanism) instead of prepending to system_message - Removed unused 're' import from streaming.py - Updated tests to match config-based approach Fixes #139 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
225 lines
7.9 KiB
Python
225 lines
7.9 KiB
Python
"""
|
|
Sprint 28 Tests: /personality slash command — backend API coverage.
|
|
Tests: GET /api/personalities, POST /api/personality/set, Session.compact(),
|
|
path traversal defence, size cap, clear personality.
|
|
"""
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# Import test constants from conftest (same process — these are module-level values)
|
|
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
|
from conftest import TEST_STATE_DIR
|
|
|
|
BASE = "http://127.0.0.1:8788"
|
|
|
|
|
|
def get(path):
|
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
|
return json.loads(r.read()), r.status
|
|
|
|
|
|
def post(path, body=None):
|
|
data = json.dumps(body or {}).encode()
|
|
req = urllib.request.Request(BASE + path, data=data,
|
|
headers={"Content-Type": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read()), r.status
|
|
except urllib.error.HTTPError as e:
|
|
return json.loads(e.read()), e.code
|
|
|
|
|
|
def _personalities_dir():
|
|
"""Return the personalities directory the test server will look in.
|
|
|
|
conftest sets HERMES_HOME=TEST_STATE_DIR in the server's environment.
|
|
The server's api/profiles._DEFAULT_HERMES_HOME resolves to TEST_STATE_DIR,
|
|
so get_active_hermes_home() returns TEST_STATE_DIR, and personalities
|
|
live at TEST_STATE_DIR/personalities.
|
|
"""
|
|
p = TEST_STATE_DIR / 'personalities'
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
|
|
def _make_personality(name, content="# Test Bot\nA test personality."):
|
|
"""Create a personality directory with a SOUL.md."""
|
|
d = _personalities_dir() / name
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "SOUL.md").write_text(content)
|
|
return d
|
|
|
|
|
|
def _make_session():
|
|
"""Create a new session and return its session_id."""
|
|
d, status = post("/api/session/new", {})
|
|
assert status == 200, f"Failed to create session: {d}"
|
|
return d["session"]["session_id"]
|
|
|
|
|
|
def _cleanup_session(sid):
|
|
try:
|
|
post("/api/session/delete", {"session_id": sid})
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── GET /api/personalities ────────────────────────────────────────────────────
|
|
|
|
def test_personalities_empty_when_none_exist():
|
|
"""GET /api/personalities returns empty list when no personalities exist."""
|
|
p_dir = _personalities_dir()
|
|
for child in list(p_dir.iterdir()):
|
|
if child.is_dir() and not child.is_symlink():
|
|
shutil.rmtree(child)
|
|
d, status = get("/api/personalities")
|
|
assert status == 200
|
|
assert d.get("personalities") == []
|
|
|
|
|
|
def test_personalities_lists_from_config():
|
|
"""GET /api/personalities returns personalities from config.yaml agent.personalities.
|
|
Skipped if no personalities configured in test environment.
|
|
"""
|
|
d, status = get("/api/personalities")
|
|
assert status == 200
|
|
assert isinstance(d.get("personalities"), list)
|
|
# If personalities are configured, verify structure
|
|
for p in d.get("personalities", []):
|
|
assert "name" in p
|
|
assert "description" in p
|
|
|
|
|
|
def test_personalities_returns_empty_when_none_configured():
|
|
"""GET /api/personalities returns empty list when no personalities in config."""
|
|
# The test server starts with a clean state dir (no config.yaml),
|
|
# so agent.personalities is empty by default
|
|
d, status = get("/api/personalities")
|
|
assert status == 200
|
|
# May or may not have personalities depending on the real ~/.hermes/config.yaml
|
|
# being loaded. Just verify the structure is correct.
|
|
assert isinstance(d.get("personalities"), list)
|
|
|
|
|
|
def test_personalities_skips_non_dict_config():
|
|
"""GET /api/personalities handles non-dict agent config gracefully."""
|
|
d, status = get("/api/personalities")
|
|
assert status == 200
|
|
assert isinstance(d.get("personalities"), list)
|
|
|
|
|
|
# ── POST /api/personality/set ─────────────────────────────────────────────────
|
|
|
|
_test_personalities = {}
|
|
|
|
def _inject_personality(name, value):
|
|
"""Write a personality into the test config.yaml so the server picks it up."""
|
|
_test_personalities[name] = value
|
|
_write_test_config()
|
|
|
|
def _remove_personality(name):
|
|
"""Remove a personality from the test config.yaml."""
|
|
_test_personalities.pop(name, None)
|
|
_write_test_config()
|
|
|
|
def _write_test_config():
|
|
"""Write config.yaml with test personalities using simple YAML format."""
|
|
TEST_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
config_path = TEST_STATE_DIR / 'config.yaml'
|
|
lines = ['agent:', ' personalities:']
|
|
for pname, pval in _test_personalities.items():
|
|
if isinstance(pval, dict):
|
|
lines.append(f' {pname}:')
|
|
for k, v in pval.items():
|
|
lines.append(f' {k}: "{v}"')
|
|
else:
|
|
lines.append(f' {pname}: "{pval}"')
|
|
config_path.write_text('\n'.join(lines) + '\n')
|
|
|
|
|
|
def test_set_personality_valid():
|
|
"""Setting a personality that exists in config stores name and returns prompt.
|
|
Skipped if config.yaml has no personalities (common in test environments).
|
|
"""
|
|
# First check if any personalities are configured
|
|
d, status = get("/api/personalities")
|
|
if not d.get("personalities"):
|
|
return # skip — no personalities in test server config
|
|
name = d["personalities"][0]["name"]
|
|
sid = _make_session()
|
|
try:
|
|
d2, status2 = post("/api/personality/set", {"session_id": sid, "name": name})
|
|
assert status2 == 200
|
|
assert d2.get("ok") is True
|
|
assert d2.get("personality") == name
|
|
finally:
|
|
_cleanup_session(sid)
|
|
|
|
|
|
def test_set_personality_persists_in_compact():
|
|
"""After setting personality, GET /api/session returns personality in compact.
|
|
Skipped if config.yaml has no personalities.
|
|
"""
|
|
d, status = get("/api/personalities")
|
|
if not d.get("personalities"):
|
|
return # skip
|
|
name = d["personalities"][0]["name"]
|
|
sid = _make_session()
|
|
try:
|
|
post("/api/personality/set", {"session_id": sid, "name": name})
|
|
d2, status2 = get(f"/api/session?session_id={sid}")
|
|
assert status2 == 200
|
|
session = d2.get("session", {})
|
|
assert session.get("personality") == name
|
|
finally:
|
|
_cleanup_session(sid)
|
|
|
|
|
|
def test_clear_personality_sets_null():
|
|
"""Clearing personality with name='' sets it to None (null in JSON)."""
|
|
sid = _make_session()
|
|
try:
|
|
# Set a personality name directly on the session (no config validation needed for clear)
|
|
d, status = post("/api/personality/set", {"session_id": sid, "name": ""})
|
|
assert status == 200
|
|
assert d.get("personality") is None
|
|
# Verify persisted
|
|
d2, s2 = get(f"/api/session?session_id={sid}")
|
|
assert s2 == 200
|
|
assert d2.get("session", {}).get("personality") is None
|
|
finally:
|
|
_cleanup_session(sid)
|
|
|
|
|
|
def test_set_personality_not_found_returns_404():
|
|
"""Setting a non-existent personality returns 404."""
|
|
sid = _make_session()
|
|
try:
|
|
d, status = post("/api/personality/set",
|
|
{"session_id": sid, "name": "doesnotexist"})
|
|
assert status == 404
|
|
finally:
|
|
_cleanup_session(sid)
|
|
|
|
|
|
def test_set_personality_nonexistent_returns_404():
|
|
"""Names not in config.yaml agent.personalities return 404."""
|
|
sid = _make_session()
|
|
try:
|
|
d, status = post("/api/personality/set",
|
|
{"session_id": sid, "name": "doesnotexist"})
|
|
assert status == 404, f"Expected 404, got {status}: {d}"
|
|
finally:
|
|
_cleanup_session(sid)
|
|
|
|
|
|
def test_set_personality_missing_session_returns_404():
|
|
"""Setting personality on non-existent session returns 404."""
|
|
d, status = post("/api/personality/set",
|
|
{"session_id": "nonexistent000", "name": "x"})
|
|
assert status == 404
|