diff --git a/tests/tools/test_bot_mode_dm.py b/tests/tools/test_bot_mode_dm.py index f2b3a8a7de..8088ee24dd 100644 --- a/tests/tools/test_bot_mode_dm.py +++ b/tests/tools/test_bot_mode_dm.py @@ -16,7 +16,7 @@ from pathlib import Path import pytest -from tools import bot_mode_dm, bot_mode_probe +from tools import bot_mode_dm, bot_mode_probe, bot_relay @pytest.fixture(autouse=True) @@ -234,6 +234,9 @@ def _runner_author(command): def test_local_delivery_command_and_ack(tmp_path, monkeypatch): calls = _capture_spawn(monkeypatch) + # These assertions target the -p/turn-args shape; pin the entrypoint resolution + # so the test stays hermetic across venvs that do/don't expose a sibling script. + monkeypatch.setattr(bot_relay, "_hermes_cli", lambda: "hermes") home = _managed_home(tmp_path, teammates=("researcher",)) agent = _FakeAgent(home, title="Bot Chat") @@ -295,6 +298,7 @@ def test_peer_delivery_command_pins_registry_profile_for_secondary_bots( tool-side roster (read from the machine-root config) validated the target.""" calls = _capture_spawn(monkeypatch) + monkeypatch.setattr(bot_relay, "_hermes_cli", lambda: "hermes") home = _managed_home(tmp_path, peers=("spark",)) # A reviewer-profile gateway context: the agent's session db lives under # that profile's home, so _agent_home() resolves there while the @@ -316,6 +320,7 @@ def test_peer_delivery_command_pins_registry_profile_for_secondary_bots( def test_peer_delivery_command(tmp_path, monkeypatch): calls = _capture_spawn(monkeypatch) + monkeypatch.setattr(bot_relay, "_hermes_cli", lambda: "hermes") monkeypatch.setattr("socket.gethostname", lambda: "eri-mac.local") home = _managed_home(tmp_path, peers=("spark",)) agent = _FakeAgent(home, title="Bot Chat") @@ -341,6 +346,40 @@ def test_peer_delivery_command(tmp_path, monkeypatch): assert transport_argv == ["hermes", "-p", "default", "peer", "dm", "spark"] +def test_delivery_pins_the_hermes_entrypoint_beside_this_interpreter(tmp_path, monkeypatch): + """A background delivery must not rely on PATH: the runner's service context + lacks the gateway's venv bin dir, so a bare ``hermes`` resolves to a system + install whose shebang picks the wrong interpreter and dies on import (#108628). + Both transports must invoke the entrypoint beside this interpreter instead.""" + venv_bin = tmp_path / "venv" / ("Scripts" if sys.platform == "win32" else "bin") + venv_bin.mkdir(parents=True) + hermes_entry = venv_bin / ("hermes.exe" if sys.platform == "win32" else "hermes") + hermes_entry.write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setattr(sys, "executable", str(venv_bin / "python3")) + + calls = _capture_spawn(monkeypatch) + home = _managed_home(tmp_path, teammates=("researcher",), peers=("spark",)) + agent = _FakeAgent(home, title="Bot Chat") + + result = json.loads( + bot_mode_dm.message_agent_tool(target="researcher", message="ping", agent=agent) + ) + assert result["status"] == "sent" + mode, _dm_file, transport_argv = _runner_parts(calls[0]["command"]) + assert mode == "query-file" + assert transport_argv[0] == str(hermes_entry) + assert transport_argv[1:] == ["-p", "researcher", "chat", "--in", "~", "-c", "Bot Chat", + "--create-if-missing", "-Q"] + + result2 = json.loads( + bot_mode_dm.message_agent_tool(target="spark", message="ping", agent=agent) + ) + assert result2["status"] == "sent" + mode, _dm_file, transport_argv = _runner_parts(calls[1]["command"]) + assert mode == "stdin" + assert transport_argv == [str(hermes_entry), "-p", "default", "peer", "dm", "spark"] + + def test_peer_delivery_author_carries_the_sender_hostname_and_local_stays_bare(tmp_path, monkeypatch): """A peer dm crosses installs, so its author id is ``bot:/``: the peer's own ``coder`` and a remote ``coder`` must not share one id. A teammate on this install still sees the bare ``bot:coder``.""" diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 99d31317bf..7261c61d75 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -183,7 +183,7 @@ def message_agent_tool(target: str = "", message: str = "", task_id: Optional[st BOT_CHAT_TITLE, _handle, _hermes_root, _peers, _profile_name as _self_profile_name, _roster, is_bot_mode_managed, ) - from tools.bot_relay import BOT_CHAT_TURN_ARGS + from tools.bot_relay import BOT_CHAT_TURN_ARGS, _hermes_cli if _session_title(agent) != BOT_CHAT_TITLE: return _err("message_agent is only available in a Bot Mode 'Bot Chat' session. " @@ -231,7 +231,12 @@ def message_agent_tool(target: str = "", message: str = "", task_id: Optional[st # Pin the registry-owning profile: `hermes peer` resolves bot_peers via the profile-scoped # load_config(), while the roster above reads the machine-root config — the CLI must run # in that same profile or a secondary-profile bot sees an empty registry. - return _start_delivery(["hermes", "-p", _self_profile_name(root), "peer", "dm", dm_target], content, + # The delivery runs in a background service context whose PATH lacks the gateway's + # venv bin dir, so a bare "hermes" resolves to a system install and dies on import + # under the wrong interpreter (#108628). _hermes_cli pins the entrypoint beside + # this interpreter; _delivery_lock/_local_delivery_home match argv[0] by basename, + # so the absolute path stays compatible. + return _start_delivery([_hermes_cli(), "-p", _self_profile_name(root), "peer", "dm", dm_target], content, f"@{peer_profile or peer_name} on peer '{peer_name}'", stdin_file=True, author=peer_author, **delivery) @@ -252,7 +257,7 @@ def message_agent_tool(target: str = "", message: str = "", task_id: Optional[st return _roster_err(f"No teammate named '{raw_target}' on this install, on a connected " "machine, or on a registered peer. Pick a name from the roster " "(roles are listed in your system prompt).") - return _start_delivery(["hermes", "-p", resolved, *BOT_CHAT_TURN_ARGS], content, f"@{_handle(resolved)}", + return _start_delivery([_hermes_cli(), "-p", resolved, *BOT_CHAT_TURN_ARGS], content, f"@{_handle(resolved)}", stdin_file=False, profile_home=roster_homes[resolved], author=author, **delivery)