Files
hermes-agent/tests/agent/test_system_prompt.py
T
marzukia 9b9cbdd7eb fix(system_prompt): move skills index to the volatile band
The skills index is runtime-mutable: the agent adds and patches skills mid-session, so it is not byte-stable. Keeping it in the stable band breaks that band prefix-cache contract, because every skill edit changes the stable band and invalidates the entire cached prefix in front of it. Move it to the front of the volatile band so the stable scaffold (identity, tool guidance, model guidance) stays cacheable across skill edits.
2026-08-03 23:23:39 +05:30

269 lines
11 KiB
Python

"""Tests for agent/system_prompt.py — context-file cwd wiring."""
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from agent.system_prompt import build_system_prompt, build_system_prompt_parts
def _make_agent(**overrides):
base = dict(
load_soul_identity=False,
skip_context_files=False,
valid_tool_names=[],
_task_completion_guidance=False,
_tool_use_enforcement=False,
_environment_probe=False,
_kanban_worker_guidance="",
_memory_store=None,
_memory_manager=None,
model="",
provider="",
platform="",
pass_session_id=False,
session_id="",
)
base.update(overrides)
return SimpleNamespace(**base)
def _captured_context_cwd(agent):
"""The cwd build_system_prompt_parts hands to build_context_files_prompt."""
captured = {}
def fake_context_files(
cwd=None, skip_soul=False, context_length=None,
allow_install_tree_fallback=False,
):
captured["cwd"] = cwd
return ""
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
):
build_system_prompt_parts(agent)
return captured["cwd"]
class TestContextFileCwd:
def test_none_when_terminal_cwd_unset(self, monkeypatch):
# Unset → None, so discovery falls back to the launch dir inside
# build_context_files_prompt (the local-CLI #19242 contract).
monkeypatch.delenv("TERMINAL_CWD", raising=False)
assert _captured_context_cwd(_make_agent()) is None
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert _captured_context_cwd(_make_agent()) == tmp_path
def _stable_prompt(agent):
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value=""),
):
return build_system_prompt_parts(agent)["stable"]
def _prompt_parts(agent):
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value=""),
):
return build_system_prompt_parts(agent)
def _init_code_repo(path):
"""A git repo that actually holds code — the coding posture requires a source
file (or manifest), not a bare ``.git`` (a prose/notes repo stays general)."""
import subprocess
subprocess.run(["git", "-C", str(path), "init", "-q"], check=True)
(path / "main.py").write_text("print('hi')\n")
class TestCodingContextBlock:
def test_injected_when_active(self, monkeypatch, tmp_path):
_init_code_repo(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
parts = _prompt_parts(agent)
assert "coding agent" in parts["stable"]
assert "Workspace" in parts["context"]
def test_absent_when_off(self, monkeypatch, tmp_path):
_init_code_repo(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
# Drive the real path: force the resolved mode to "off" via config.
with patch("agent.coding_context._coding_mode", return_value="off"):
stable = _stable_prompt(agent)
assert "coding agent" not in stable
def test_absent_without_tools(self, monkeypatch, tmp_path):
_init_code_repo(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
agent = _make_agent(valid_tool_names=[], platform="cli")
assert "coding agent" not in _stable_prompt(agent)
def test_build_system_prompt_records_stable_prefix():
agent = _make_agent()
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value="context"),
):
prompt = build_system_prompt(agent)
assert prompt.startswith(agent._cached_system_prompt_static)
assert prompt[len(agent._cached_system_prompt_static):].startswith("\n\ncontext")
def test_coding_prompt_preserves_legacy_workspace_order(monkeypatch):
"""The cache split must not reorder the stored coding prompt."""
import agent.system_prompt as system_prompt
agent = _make_agent(
valid_tool_names=["read_file"],
_parallel_tool_call_guidance=False,
)
monkeypatch.setattr(system_prompt, "DEFAULT_AGENT_IDENTITY", "IDENTITY")
monkeypatch.setattr(system_prompt, "HERMES_AGENT_HELP_GUIDANCE", "HELP")
monkeypatch.setattr(system_prompt, "STEER_CHANNEL_NOTE", "STEER")
monkeypatch.setattr(system_prompt, "get_hermes_home", lambda: Path("/hermes"))
expected_profile = (
"Active Hermes profile: default. Other profiles (if any) live "
"under /hermes/profiles/<name>/. Each profile has its own skills/, "
"plugins/, cron/, and memories/ that affect a different session than "
"this one. Do not modify another profile's skills/plugins/cron/memories "
"unless the user explicitly directs you to."
)
expected = "\n\n".join((
"IDENTITY",
"HELP",
"STEER",
"CODING_STABLE",
"WORKSPACE",
"Operator instructions (from config):\nOPERATOR",
expected_profile,
"SYSTEM_MESSAGE",
"CONTEXT_FILES",
"Conversation started: Friday, January 02, 2026",
))
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value="CONTEXT_FILES"),
patch(
"agent.coding_context.coding_system_prompt_parts",
return_value=(
["CODING_STABLE"],
["WORKSPACE"],
["Operator instructions (from config):\nOPERATOR"],
),
),
patch("agent.file_safety._resolve_active_profile_name", return_value="default"),
patch("hermes_time.now", return_value=datetime(2026, 1, 2)),
):
prompt = build_system_prompt(agent, system_message="SYSTEM_MESSAGE")
assert prompt == expected
assert agent._cached_system_prompt_static == "\n\n".join(expected.split("\n\n")[:4])
class TestTelegramRichMessagesHint:
"""Verify that TELEGRAM_RICH_MESSAGES_HINT is conditionally included."""
def test_base_hint_without_rich_messages(self, monkeypatch):
"""When rich_messages is False (default), only the base hint is used."""
agent = _make_agent(platform="telegram")
# Mock config to return rich_messages: false (default)
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
mock_cfg.return_value = {
"platforms": {"telegram": {"extra": {"rich_messages": False}}}
}
stable = _stable_prompt(agent)
# Base hint should be present
assert "Standard Markdown is automatically converted" in stable
# Rich-messages extension should NOT be present
assert "lean into it" not in stable
assert "task lists" not in stable
def test_rich_hint_with_rich_messages_enabled(self, monkeypatch):
"""When rich_messages is True, the rich-messages extension is appended."""
agent = _make_agent(platform="telegram")
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
mock_cfg.return_value = {
"platforms": {"telegram": {"extra": {"rich_messages": True}}}
}
stable = _stable_prompt(agent)
# Base hint should be present
assert "Standard Markdown is automatically converted" in stable
# Rich-messages extension should be present
assert "lean into it" in stable
assert "task lists" in stable
assert "math/formulas" in stable
def test_base_hint_without_config(self, monkeypatch):
"""When config has no telegram section, only base hint is used."""
agent = _make_agent(platform="telegram")
with patch("hermes_cli.config.load_config_readonly") as mock_cfg:
mock_cfg.return_value = {}
stable = _stable_prompt(agent)
assert "Standard Markdown is automatically converted" in stable
assert "lean into it" not in stable
_SKILLS = "SKILLS_INDEX_SENTINEL"
_CONTEXT = "CONTEXT_FILES_SENTINEL"
def _build(builder, **overrides):
"""Run a build_* function with skills + context files present."""
agent = _make_agent(valid_tool_names=["skills_list"], **overrides)
with (
patch("run_agent.load_soul_md", return_value=""),
patch("run_agent.build_nous_subscription_prompt", return_value=""),
patch("run_agent.build_environment_hints", return_value=""),
patch("run_agent.build_context_files_prompt", return_value=_CONTEXT),
patch("run_agent.get_toolset_for_tool", return_value=None),
patch("run_agent.build_skills_system_prompt", return_value=_SKILLS),
):
return builder(agent)
class TestSkillsInVolatileBand:
"""The skills index is runtime-mutable, so it lives in the volatile band,
not the stable band, to keep the cached stable prefix reusable when a
rebuild picks up a skill change."""
def test_skills_not_in_stable_band(self):
parts = _build(build_system_prompt_parts)
assert _SKILLS not in parts["stable"]
def test_skills_lead_the_volatile_band(self):
parts = _build(build_system_prompt_parts)
assert parts["volatile"].startswith(_SKILLS)
def test_full_order_is_stable_context_then_skills(self):
# build_system_prompt joins stable + context + volatile, so the skills
# index renders after the context files and before the per-turn
# memory/timestamp tail.
full = _build(build_system_prompt)
assert full.index(_CONTEXT) < full.index(_SKILLS)
assert full.index(_SKILLS) < full.index("Conversation started:")