"""Tests for tools/bot_mode_dm.py — the Bot-Chat-only ``message_agent`` tool. The containment contract is the headline here: the tool must exist ONLY in a canonical Bot Chat session on a Bot-Mode-managed install, and must refuse to deliver from anywhere else even if a schema leaks. """ import json import textwrap from pathlib import Path import pytest from tools import bot_mode_dm, bot_mode_probe @pytest.fixture(autouse=True) def _fresh_probe_cache(): bot_mode_probe._reset_cache_for_tests() yield bot_mode_probe._reset_cache_for_tests() def _managed_home(tmp_path, *, teammates=("researcher",), peers=()) -> Path: home = tmp_path / ".hermes" home.mkdir(exist_ok=True) for name in teammates: d = home / "profiles" / name d.mkdir(parents=True, exist_ok=True) (d / "profile.yaml").write_text( textwrap.dedent( """\ description: teammate for tests ui_meta: hermes-bots: shape: cloud """ ), encoding="utf-8", ) if peers: lines = ["bot_peers:"] for peer in peers: lines += [f" {peer}:", f" url: http://{peer}.lan:8377"] (home / "config.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") return home class _FakeDB: def __init__(self, home: Path, title: str): self.db_path = str(home / "state.db") self._title = title def get_session_title(self, _sid): return self._title class _FakeAgent: def __init__(self, home: Path, title: str = "Bot Chat"): self._session_db = _FakeDB(home, title) self.session_id = "sess-1" self._session_title_hint = None self._bot_mode_protocol = True self.tools: list = [] self.valid_tool_names: set = set() # ── injection gate (leak containment) ──────────────────────────────────────── def test_injects_only_into_bot_chat_on_managed_install(tmp_path): home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Bot Chat") assert bot_mode_dm.ensure_message_agent_tool(agent) is True names = [t["function"]["name"] for t in agent.tools] assert names == [bot_mode_dm.MESSAGE_AGENT_TOOL_NAME] assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME in agent.valid_tool_names # idempotent: second call adds nothing (byte-stable tool list per turn) assert bot_mode_dm.ensure_message_agent_tool(agent) is True assert len(agent.tools) == 1 @pytest.mark.parametrize( "title", ["", "My research chat", "Group: room-abc123", "handoff-12ab34cd"], ) def test_never_injects_outside_bot_chat(tmp_path, title): """CLI sessions, ordinary chats, group-room member sessions: no tool.""" home = _managed_home(tmp_path) agent = _FakeAgent(home, title=title) assert bot_mode_dm.ensure_message_agent_tool(agent) is False assert agent.tools == [] assert agent.valid_tool_names == set() def test_never_injects_on_unmanaged_install(tmp_path): """A 'Bot Chat'-titled session on a plain install stays tool-free.""" home = tmp_path / ".hermes" home.mkdir() agent = _FakeAgent(home, title="Bot Chat") assert bot_mode_dm.ensure_message_agent_tool(agent) is False assert agent.tools == [] def test_config_toggle_disables_injection(tmp_path): home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Bot Chat") agent._bot_mode_protocol = False assert bot_mode_dm.ensure_message_agent_tool(agent) is False assert agent.tools == [] def test_schema_never_in_global_registry(): """message_agent must not be registered/toolset-reachable anywhere.""" from tools.registry import registry assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME not in getattr(registry, "_tools", {}) import toolsets for names in toolsets.TOOLSETS.values(): assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME not in names # ── dispatch gate (defense in depth) ───────────────────────────────────────── def test_tool_refuses_outside_bot_chat(tmp_path): home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Ordinary chat") result = json.loads( bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent) ) assert "error" in result assert "Bot Chat" in result["error"] def test_tool_refuses_on_unmanaged_install(tmp_path): home = tmp_path / ".hermes" home.mkdir() agent = _FakeAgent(home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent) ) assert "error" in result # ── target validation ──────────────────────────────────────────────────────── def test_unknown_target_lists_roster(tmp_path): home = _managed_home(tmp_path, teammates=("researcher", "coder")) agent = _FakeAgent(home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool(target="nosuchbot", message="hi", agent=agent) ) assert "error" in result assert set(result["teammates"]) == {"researcher", "coder"} def test_cannot_message_self(tmp_path): home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Bot Chat") # default profile result = json.loads( bot_mode_dm.message_agent_tool(target="hermes", message="hi", agent=agent) ) assert "error" in result assert "yourself" in result["error"] def test_empty_and_oversized_message_rejected(tmp_path): home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Bot Chat") assert "error" in json.loads( bot_mode_dm.message_agent_tool(target="researcher", message=" ", agent=agent) ) big = "x" * (bot_mode_dm.MESSAGE_MAX_CHARS + 1) assert "error" in json.loads( bot_mode_dm.message_agent_tool(target="researcher", message=big, agent=agent) ) def test_unregistered_peer_rejected(tmp_path): home = _managed_home(tmp_path, peers=("spark",)) agent = _FakeAgent(home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool(target="homelab/coder", message="hi", agent=agent) ) assert "error" in result assert result["peers"] == ["spark"] # ── delivery command shape ─────────────────────────────────────────────────── def _capture_spawn(monkeypatch): calls = [] def fake_terminal_tool(command, **kwargs): calls.append({"command": command, **kwargs}) return json.dumps({"output": "Background process started", "session_id": "proc_test1234"}) import tools.terminal_tool as terminal_tool_module monkeypatch.setattr(terminal_tool_module, "terminal_tool", fake_terminal_tool) return calls def test_local_delivery_command_and_ack(tmp_path, monkeypatch): calls = _capture_spawn(monkeypatch) home = _managed_home(tmp_path, teammates=("researcher",)) agent = _FakeAgent(home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool( target="@researcher", message='status? give me the "final" numbers $(and this is not shell)', agent=agent, ) ) assert result["status"] == "sent" assert result["to"] == "@researcher" assert result["process_id"] == "proc_test1234" assert "do NOT wait" in result["detail"] assert len(calls) == 1 call = calls[0] assert call["background"] is True assert call["notify_on_complete"] is True command = call["command"] assert command.startswith("hermes -p researcher chat --in ~ -c \"Bot Chat\"") assert "--query-file" in command # message body rides the temp file, never the command line assert "final" not in command assert "$(" not in command # attribution prefix applied server-side; body verbatim inside the file dm_file = command.rsplit(" ", 1)[-1].strip("'") content = Path(dm_file).read_text(encoding="utf-8") assert content.startswith("Message from 🤖 hermes (@hermes): ") assert '$(and this is not shell)' in content def test_peer_delivery_command(tmp_path, monkeypatch): calls = _capture_spawn(monkeypatch) home = _managed_home(tmp_path, peers=("spark",)) agent = _FakeAgent(home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool(target="spark/researcher", message="ping", agent=agent) ) assert result["status"] == "sent" assert "spark" in result["to"] command = calls[0]["command"] assert command.startswith("hermes peer dm spark/researcher < ") # bare peer name targets the peer's main agent result2 = json.loads( bot_mode_dm.message_agent_tool(target="spark", message="ping", agent=agent) ) assert result2["status"] == "sent" assert calls[1]["command"].startswith("hermes peer dm spark < ") def test_named_profile_sender_prefix(tmp_path, monkeypatch): """A named-profile bot signs with its own handle, not @hermes.""" calls = _capture_spawn(monkeypatch) home = _managed_home(tmp_path, teammates=("researcher", "coder")) profile_home = home / "profiles" / "coder" agent = _FakeAgent(profile_home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent) ) assert result["status"] == "sent" dm_file = calls[0]["command"].rsplit(" ", 1)[-1].strip("'") assert Path(dm_file).read_text(encoding="utf-8").startswith( "Message from 🤖 coder (@coder): " ) def test_spawn_failure_reports_error(tmp_path, monkeypatch): home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Bot Chat") import tools.terminal_tool as terminal_tool_module def boom(command, **kwargs): raise RuntimeError("spawn failed") monkeypatch.setattr(terminal_tool_module, "terminal_tool", boom) result = json.loads( bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent) ) assert "error" in result assert "could not be started" in result["error"]