"""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//. 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, only the base hint is used.""" agent = _make_agent(platform="telegram") with patch("hermes_cli.config.load_config_readonly") as mock_cfg: mock_cfg.return_value = { "gateway": {"platforms": {"telegram": {"extra": {"rich_messages": False}}}} } stable = _stable_prompt(agent) assert "Standard Markdown is automatically converted" in stable 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 in gateway.platforms, the extension is appended (the canonical/primary location).""" agent = _make_agent(platform="telegram") with patch("hermes_cli.config.load_config_readonly") as mock_cfg: mock_cfg.return_value = { "gateway": {"platforms": {"telegram": {"extra": {"rich_messages": True}}}} } stable = _stable_prompt(agent) assert "lean into it" in stable assert "task lists" in stable assert "math/formulas" in stable def test_rich_hint_from_top_level_platforms(self): """Top-level ``platforms.telegram.extra.rich_messages`` is merged alongside gateway.platforms, so it works on its own.""" 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) assert "lean into it" in stable assert "task lists" in stable def test_top_level_overrides_gateway_rich_messages(self): """Top-level ``platforms.telegram.extra`` wins over gateway.platforms at the leaf, matching the adapter's merge precedence.""" agent = _make_agent(platform="telegram") with patch("hermes_cli.config.load_config_readonly") as mock_cfg: mock_cfg.return_value = { "gateway": {"platforms": {"telegram": {"extra": {"rich_messages": False}}}}, "platforms": {"telegram": {"extra": {"rich_messages": True}}}, } stable = _stable_prompt(agent) assert "lean into it" in stable def test_gateway_extra_other_keys_does_not_block_top_level_rich_messages(self): """When gateway.platforms.telegram.extra has other keys but not rich_messages, the top-level rich_messages still activates.""" agent = _make_agent(platform="telegram") with patch("hermes_cli.config.load_config_readonly") as mock_cfg: mock_cfg.return_value = { "gateway": {"platforms": {"telegram": {"extra": {"disable_link_previews": True}}}}, "platforms": {"telegram": {"extra": {"rich_messages": True}}}, } stable = _stable_prompt(agent) assert "lean into it" 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 def test_gateway_rich_messages_integration_via_real_config(self, tmp_path, monkeypatch): """End-to-end through the real config-resolution chain: a config.yaml under HERMES_HOME with ``gateway.platforms.telegram.extra.rich_messages`` must activate the rich hint. ``load_config_readonly`` is NOT mocked here, so this guards against the exact path-mismatch bug this PR fixes. """ config_yaml = ( "gateway:\n" " platforms:\n" " telegram:\n" " extra:\n" " rich_messages: true\n" ) home = tmp_path / "hermes_home" home.mkdir() (home / "config.yaml").write_text(config_yaml) monkeypatch.setenv("HERMES_HOME", str(home)) # Point config resolution at the temp file without mocking the loader: # mirror the pattern used in test_config_env_expansion.py. from hermes_cli import config as _cfgmod monkeypatch.setattr(_cfgmod, "get_config_path", lambda: home / "config.yaml") agent = _make_agent(platform="telegram") stable = _stable_prompt(agent) assert "lean into it" in stable assert "task lists" in stable def test_malformed_extra_value_falls_back_to_base_hint(self, tmp_path, monkeypatch): """A truthy non-mapping ``extra`` must not crash prompt construction — it should fail open to the base hint (Tek's fail-open concern). """ agent = _make_agent(platform="telegram") with patch("hermes_cli.config.load_config_readonly") as mock_cfg: mock_cfg.return_value = { "gateway": {"platforms": {"telegram": {"extra": "not-a-map"}}} } 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:")