"""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 os import shlex import subprocess import sys import textwrap import threading import time from pathlib import Path import pytest from tools import bot_mode_dm, bot_mode_probe, bot_relay @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 def test_restores_allowlist_when_schema_survives_surface_refresh(tmp_path): """#96105: success must restore the executor allowlist on the schema-present branch. A long-lived Bot Chat whose tool surface is reconstructed can keep the schema while the executor's allowlist is rebuilt empty. The injector then returned True while dispatch would reject every ``message_agent`` call — advertised but non-dispatchable. Restore-on-success contract: whenever ``ensure_message_agent_tool()`` returns True, ``MESSAGE_AGENT_TOOL_NAME`` is in ``valid_tool_names`` whenever that attribute is a set. """ home = _managed_home(tmp_path) agent = _FakeAgent(home, title="Bot Chat") assert bot_mode_dm.ensure_message_agent_tool(agent) is True assert len(agent.tools) == 1 # capability refresh: schema survives, executor allowlist rebuilt empty agent.valid_tool_names = set() assert bot_mode_dm.ensure_message_agent_tool(agent) is True assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME in agent.valid_tool_names # byte-stable: no duplicate schema was appended 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 _runner_parts(command): """(mode, dm_file, transport argv) of a runner command. The optional ``--author `` pair is skipped.""" parts = shlex.split(command) marker = parts.index("--run-delivery") if parts[marker + 1] == "--author": marker += 2 argv = parts[marker + 3 :] if argv[:1] == ["--profile-home"]: argv = argv[2:] return parts[marker + 1], parts[marker + 2], argv def _runner_author(command): """The parsed ``--author`` payload of a runner command, or None when the pair is absent.""" parts = shlex.split(command) marker = parts.index("--run-delivery") if parts[marker + 1] != "--author": return None return json.loads(parts[marker + 2]) 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") result = json.loads( bot_mode_dm.message_agent_tool( target="@researcher", message=( 'status? give me the "PAYLOAD_SENTINEL_7A91" numbers ' "$(and this is not shell)" ), agent=agent, ) ) assert result["status"] == "queued" assert result["to"] == "@researcher" assert result["process_id"] == "proc_test1234" assert len(calls) == 1 call = calls[0] assert call["background"] is True assert call["notify_on_complete"] is True assert call["_host_local"] is True # The completion notification IS the reply: sized like a message, not a build log's 2000-char tail. assert call["_completion_output_chars"] > bot_mode_dm.MESSAGE_MAX_CHARS assert Path(call["workdir"]) == Path(bot_mode_dm.__file__).resolve().parent.parent command = call["command"] mode, dm_file, transport_argv = _runner_parts(command) assert mode == "query-file" assert transport_argv == [ "hermes", "-p", "researcher", "chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", "-Q", ] # message body rides the temp file, never the command line assert "PAYLOAD_SENTINEL_7A91" not in command assert "$(" not in command # the sender rides the runner argv as a stable id plus display handle assert _runner_author(command) == {"id": "bot:default", "name": "hermes", "is_bot": True} # attribution prefix applied server-side; body verbatim inside the file 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_cli_runner_ack_is_queued_with_the_runner_delivery_id(tmp_path, monkeypatch): """The CLI-runner ack speaks the same vocabulary as the live-owner and relay branches: ``queued`` + ``delivery_id`` (+ ``process_id``). The id is the one the runner itself pins for the same DM file when it admits to a live owner, so a sender can correlate both.""" import hashlib calls = _capture_spawn(monkeypatch) home = _managed_home(tmp_path, teammates=("researcher",)) result = json.loads(bot_mode_dm.message_agent_tool( target="researcher", message="hi", agent=_FakeAgent(home, title="Bot Chat"))) assert result["status"] == "queued" assert result["process_id"] == "proc_test1234" _, dm_file, _ = _runner_parts(calls[0]["command"]) assert result["delivery_id"] == hashlib.sha256(str(Path(dm_file).resolve()).encode()).hexdigest() def test_relay_ack_is_queued_with_the_envelope_id(tmp_path, monkeypatch): _capture_spawn(monkeypatch) home = _managed_home(tmp_path) bot_relay.write_remote_roster(home, [ {"profile": "default", "handle": "hermes", "connection_id": "cloud-1", "connection_label": "Hermes Cloud"}, ]) result = json.loads(bot_mode_dm.message_agent_tool(target="hermes", message="ping", agent=_FakeAgent(home))) assert result["status"] == "queued" (envelope,) = bot_relay.claim_pending_envelopes(home) assert result["delivery_id"] == envelope["id"] def _rename(home: Path, folder: str, *, display_name: str = "", title: str = "") -> None: lines = ["description: teammate for tests", "ui_meta:", " hermes-bots:", " shape: cloud"] if title: lines.append(f" title: {title}") if display_name: lines.append(f"display_name: {display_name}") (home / "profiles" / folder / "profile.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") @pytest.mark.parametrize("target", ["Scribe", "@scribe", "Dr. Foo", "dr-foo", "drfoo", "Builder"]) def test_friendly_names_and_desktop_slugs_resolve_to_folder_ids(tmp_path, monkeypatch, target): """A display name, Bot Mode title or the Desktop's @-slug of either lands on the folder id message_agent keys on — the same aliases the composer autocompletes (#100671).""" calls = _capture_spawn(monkeypatch) monkeypatch.setattr(bot_relay, "_hermes_cli", lambda: "hermes") home = _managed_home(tmp_path, teammates=("writer", "foo", "builder")) _rename(home, "writer", display_name="Scribe") _rename(home, "foo", title="Dr. Foo") _rename(home, "builder", display_name="Builder") expected = {"Scribe": "writer", "@scribe": "writer", "Dr. Foo": "foo", "dr-foo": "foo", "drfoo": "foo", "Builder": "builder"}[target] result = json.loads(bot_mode_dm.message_agent_tool(target=target, message="ping", agent=_FakeAgent(home))) assert result["status"] == "queued", result assert result["to"] == f"@{expected}" _mode, _dm_file, argv = _runner_parts(calls[0]["command"]) assert argv[1:3] == ["-p", expected] @pytest.mark.parametrize(("target", "local_name", "relayed"), [ ("hermes@mini", "Hermes Mini", True), ("@hermes@mini", "HermesMini", True), ("Ops@Home", "Ops@Home", False), # an '@' friendly name no connection answers to stays local (#100671) ]) def test_connection_qualified_target_reaches_the_relay_not_a_look_alike_local_bot( tmp_path, monkeypatch, target, local_name, relayed): """'hermes@mini' is the form the relay hands out, and stamps on replies, for a remote row whose bare forms collide. Resolved locally first, a local bot whose friendly name slugs to 'hermes-mini' captured it: the DM and its reply thread landed in the wrong bot's transcript and memory.""" calls = _capture_spawn(monkeypatch) monkeypatch.setattr(bot_relay, "_hermes_cli", lambda: "hermes") home = _managed_home(tmp_path, teammates=("ops",)) _rename(home, "ops", display_name=local_name) bot_relay.write_remote_roster(home, [ {"profile": "default", "handle": "hermes", "connection_id": "mini", "connection_label": "Mini"}, ]) result = json.loads(bot_mode_dm.message_agent_tool(target=target, message="status?", agent=_FakeAgent(home))) local = [_runner_parts(c["command"])[2][1:3] for c in calls if "--run-delivery" in c["command"]] envelopes = bot_relay.claim_pending_envelopes(home) if relayed: assert [e["target_connection"] for e in envelopes] == ["mini"], result assert local == [] else: assert envelopes == [] and result["to"] == "@ops" assert local == [["-p", "ops"]] def test_ambiguous_friendly_name_fails_closed(tmp_path, monkeypatch): """Two bots titled the same must not let a DM land on whichever sorts first; the reserved @hermes alias can never be hijacked by a rename.""" calls = _capture_spawn(monkeypatch) home = _managed_home(tmp_path, teammates=("aaa", "bbb", "ops")) _rename(home, "aaa", display_name="Scribe") _rename(home, "bbb", display_name="Scribe") _rename(home, "ops", display_name="Hermes") ambiguous = json.loads(bot_mode_dm.message_agent_tool(target="Scribe", message="ping", agent=_FakeAgent(home))) hijack = json.loads(bot_mode_dm.message_agent_tool(target="hermes", message="ping", agent=_FakeAgent(home / "profiles" / "aaa"))) assert "error" in ambiguous assert hijack.get("to") == "@hermes" assert [_runner_parts(c["command"])[2][1:3] for c in calls] == [["-p", "default"]] def test_peer_delivery_command_pins_registry_profile_for_secondary_bots( tmp_path, monkeypatch ): """A secondary-profile bot's peer DM must run in the registry-owning profile (#93935). `hermes peer` resolves bot_peers through profile-scoped load_config(); unpinned, the subprocess inherits the calling bot's profile and dies with "No peer named" even though the 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 # machine-root config (home/config.yaml) still holds the registry. reviewer_home = home / "profiles" / "reviewer" reviewer_home.mkdir(parents=True) agent = _FakeAgent(reviewer_home, title="Bot Chat") result = json.loads( bot_mode_dm.message_agent_tool(target="spark", message="ping", agent=agent) ) assert result["status"] == "queued" mode, _dm_file, transport_argv = _runner_parts(calls[0]["command"]) assert mode == "stdin" # The registry the tool validated against is the machine root's — the # default profile's home — so the CLI runs there, not in reviewer. assert transport_argv == ["hermes", "-p", "default", "peer", "dm", "spark"] 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") result = json.loads( bot_mode_dm.message_agent_tool(target="spark/researcher", message="ping", agent=agent) ) assert result["status"] == "queued" assert "spark" in result["to"] mode, _dm_file, transport_argv = _runner_parts(calls[0]["command"]) assert mode == "stdin" assert transport_argv == ["hermes", "-p", "default", "peer", "dm", "spark/researcher"] # the peer child reads the author from its env and forwards it in the request body assert _runner_author(calls[0]["command"]) == {"id": "bot:eri-mac.local/default", "name": "hermes", "is_bot": True} # 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"] == "queued" mode, _dm_file, transport_argv = _runner_parts(calls[1]["command"]) assert mode == "stdin" 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). With no published install launcher, both transports invoke the entrypoint beside this interpreter instead; a published launcher outranks that sibling (#124868).""" 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")) # An install without a published launcher keeps the sibling fallback; keep this # checkout's own published launcher out of the resolution (#124868). monkeypatch.setattr(bot_relay, "__file__", str(tmp_path / "tools" / "bot_relay.py")) 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"] == "queued" 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"] == "queued" 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``.""" calls = _capture_spawn(monkeypatch) monkeypatch.setattr("socket.gethostname", lambda: " eri/mac\x00.local ") home = _managed_home(tmp_path, teammates=("researcher", "coder"), peers=("spark",)) agent = _FakeAgent(home / "profiles" / "coder", title="Bot Chat") assert json.loads(bot_mode_dm.message_agent_tool(target="spark", message="ping", agent=agent))["status"] == "queued" assert json.loads(bot_mode_dm.message_agent_tool(target="researcher", message="ping", agent=agent))["status"] == "queued" assert _runner_author(calls[0]["command"]) == {"id": "bot:erimac.local/coder", "name": "coder", "is_bot": True} assert _runner_author(calls[1]["command"]) == {"id": "bot:coder", "name": "coder", "is_bot": True} def test_renamed_primary_signs_with_its_friendly_name_and_is_reachable_by_it(tmp_path, monkeypatch): """#89720: `hermes profile rename default Maia` writes profile.yaml ``display_name`` (no Bot Mode title). The primary must then sign `Maia (@hermes)`, not `hermes (@hermes)`, and a teammate must reach it as `maia` / `@maia` — the tag the Desktop roster inserts — while `@hermes` keeps resolving. A Bot Mode title outranks the display_name in the signature, as in the Desktop's botFriendlyNames.""" calls = _capture_spawn(monkeypatch) monkeypatch.setattr(bot_relay, "_hermes_cli", lambda: "hermes") home = _managed_home(tmp_path, teammates=("coder",)) (home / "profile.yaml").write_text("display_name: Maia\n", encoding="utf-8") result = json.loads(bot_mode_dm.message_agent_tool(target="coder", message="hi", agent=_FakeAgent(home))) assert result["status"] == "queued" _mode, dm_file, _argv = _runner_parts(calls[0]["command"]) assert Path(dm_file).read_text(encoding="utf-8").startswith("Message from 🤖 Maia (@hermes): ") coder = _FakeAgent(home / "profiles" / "coder") for target in ("maia", "@maia", "@hermes"): result = json.loads(bot_mode_dm.message_agent_tool(target=target, message="pong", agent=coder)) assert result["status"] == "queued", (target, result) _mode, _dm_file, argv = _runner_parts(calls[-1]["command"]) assert argv[1:3] == ["-p", "default"], (target, argv) (home / "profile.yaml").write_text( "display_name: Maia\nui_meta:\n hermes-bots:\n title: Maia Prime\n", encoding="utf-8" ) json.loads(bot_mode_dm.message_agent_tool(target="coder", message="hi", agent=_FakeAgent(home))) _mode, dm_file, _argv = _runner_parts(calls[-1]["command"]) assert Path(dm_file).read_text(encoding="utf-8").startswith("Message from 🤖 Maia Prime (@hermes): ") 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"] == "queued" _mode, dm_file, _transport_argv = _runner_parts(calls[0]["command"]) assert Path(dm_file).read_text(encoding="utf-8").startswith( "Message from 🤖 coder (@coder): " ) assert _runner_author(calls[0]["command"]) == {"id": "bot:coder", "name": "coder", "is_bot": True} def test_live_dm_admitted_before_waiter_failure(tmp_path, monkeypatch): from tools import bot_live_delivery as live home = _managed_home(tmp_path) target = home / "profiles" / "researcher" owner = dict(profile_home=str(target), session_id="bot", lease_id="lease", live_session_id="live") monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: owner if Path(h) == target else None) monkeypatch.setattr(bot_mode_dm, "_dm_dir", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "wrong-home")) import tools.terminal_tool as terminal monkeypatch.setattr(terminal, "terminal_tool", lambda *a, **k: json.dumps({"error": "spawn failed"})) result = json.loads(bot_mode_dm.message_agent_tool("researcher", "hello", agent=_FakeAgent(home))) assert result["status"] == "queued" record = live.read_delivery_result(target, result["delivery_id"]) assert record is not None assert record["owner"] == owner assert record["message"] == "Message from 🤖 hermes (@hermes): hello" assert record["author"] == {"id": "bot:default", "name": "hermes", "is_bot": True} assert "notification_error" in result def test_live_dm_runner_retry_never_reexecutes_failed_claim(tmp_path, monkeypatch, capsys): from tools import bot_live_delivery as live home = _managed_home(tmp_path) target = home / "profiles" / "researcher" monkeypatch.setenv("HERMES_HOME", str(home)) owner = dict(profile_home=str(target), session_id="bot", lease_id="lease", live_session_id="live") live_owner = {"owner": owner} monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: live_owner["owner"]) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("must not launch a model turn")) dm_file = tmp_path / "message.txt" dm_file.write_text("hello", encoding="utf-8") argv = ["hermes", "-p", "researcher"] claims = [] def fail_later(): # Claim only once _run_delivery has queued the DM: a fixed sleep claimed too early on a # loaded runner, got None, and the runner then waited forever on the still-live owner. deadline = time.monotonic() + 10 while not claims and time.monotonic() < deadline: claimed = live.claim_pending_delivery(target, owner) if claimed is None: time.sleep(0.01) else: claims.append(claimed) if not claims: live_owner["owner"] = None # release the runner so the test fails instead of hanging return live.complete_delivery(target, claims[0]["delivery_id"], status="failed", error="HTTP 429 rate limit") failing = threading.Thread(target=fail_later) failing.start() try: assert bot_mode_dm._run_delivery(argv, str(dm_file), stdin_file=False) == 1 finally: failing.join(timeout=12) assert claims, "the queued DM was never claimable" first = json.loads(capsys.readouterr().out) assert first["status"] == "failed" monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: None) assert bot_mode_dm._run_delivery(argv, str(dm_file), stdin_file=False) == 1 failed = json.loads(capsys.readouterr().out) assert failed["status"] == "failed" assert failed["delivery_id"] == first["delivery_id"] assert dm_file.read_text(encoding="utf-8") == "hello" def test_live_dm_wait_reports_reply_after_initial_wait_budget(tmp_path, monkeypatch, capsys): """A retained live receipt must still deliver its reply after the old 300 s boundary.""" from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="a" * 32) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: owner) def settle_later(): time.sleep(0.05) claimed = live.claim_pending_delivery(tmp_path, owner) assert claimed is not None live.complete_delivery(tmp_path, claimed["delivery_id"], status="settled", reply="PONG") settling = threading.Thread(target=settle_later) settling.start() try: exit_code = bot_mode_dm._wait_live_dm(str(tmp_path), record["delivery_id"]) finally: settling.join(timeout=2) notice = json.loads(capsys.readouterr().out) assert exit_code == 0 assert notice["status"] == "settled" assert notice["reply"] == "PONG" def test_live_dm_wait_releases_queued_runner_when_pinned_owner_is_gone(tmp_path, monkeypatch, capsys): from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="c" * 32) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: None) assert bot_mode_dm._wait_live_dm(str(tmp_path), record["delivery_id"]) == 1 notice = json.loads(capsys.readouterr().out) assert notice["status"] == "cancelled" final = live.read_delivery_result(tmp_path, record["delivery_id"]) assert final is not None and final["status"] == "cancelled" def test_live_dm_wait_rechecks_receipt_when_owner_disappears_at_claim(tmp_path, monkeypatch, capsys): from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="f" * 32) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) def settle_during_owner_check(_home): claimed = live.claim_pending_delivery(tmp_path, owner) assert claimed is not None live.complete_delivery(tmp_path, claimed["delivery_id"], status="settled", reply="PONG") return None monkeypatch.setattr(live, "find_canonical_live_owner", settle_during_owner_check) assert bot_mode_dm._wait_live_dm(str(tmp_path), record["delivery_id"]) == 0 notice = json.loads(capsys.readouterr().out) assert notice["status"] == "settled" assert notice["reply"] == "PONG" def test_live_dm_wait_keeps_claimed_turn_until_it_settles(tmp_path, monkeypatch, capsys): """An owner gone after its claim still gets one more window to settle the in-flight turn.""" from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="d" * 32) assert live.claim_pending_delivery(tmp_path, owner) is not None monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.5) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) settling = threading.Timer(0.02, lambda: live.complete_delivery( tmp_path, record["delivery_id"], status="settled", reply="PONG")) def owner_gone_then_settle(_home): settling.start() return None monkeypatch.setattr(live, "find_canonical_live_owner", owner_gone_then_settle) try: exit_code = bot_mode_dm._wait_live_dm(str(tmp_path), record["delivery_id"]) finally: settling.join(timeout=2) assert exit_code == 0 notice = json.loads(capsys.readouterr().out) assert notice["status"] == "settled" assert notice["reply"] == "PONG" def test_live_dm_wait_releases_claimed_runner_after_owner_is_gone(tmp_path, monkeypatch, capsys): from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="1" * 32) assert live.claim_pending_delivery(tmp_path, owner) is not None monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: None) outcomes = [] waiting = threading.Thread(target=lambda: outcomes.append(bot_mode_dm._wait_live_dm( str(tmp_path), record["delivery_id"])), daemon=True) waiting.start() waiting.join(timeout=1) stopped_with_unknown_outcome = not waiting.is_alive() if not stopped_with_unknown_outcome: live.complete_delivery(tmp_path, record["delivery_id"], status="failed", error="test cleanup") waiting.join(timeout=2) assert stopped_with_unknown_outcome, "a vanished owner must not retain the runner indefinitely" assert outcomes == [0] notice = json.loads(capsys.readouterr().out) assert notice["status"] == "claimed" final = live.read_delivery_result(tmp_path, record["delivery_id"]) assert final is not None and final["status"] == "claimed" @pytest.mark.parametrize("status", ["queued", "claimed"]) def test_live_dm_wait_releases_runner_when_owner_lookup_keeps_failing(tmp_path, monkeypatch, capsys, status): """A lookup that always raises is an owner that cannot be confirmed: bounded, never cancelled.""" from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="2" * 32) if status == "claimed": assert live.claim_pending_delivery(tmp_path, owner) is not None monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) def lookup_fails(_home): raise OSError("registry locked") monkeypatch.setattr(live, "find_canonical_live_owner", lookup_fails) assert bot_mode_dm._wait_live_dm(str(tmp_path), record["delivery_id"]) == 0 notice = json.loads(capsys.readouterr().out) assert notice["status"] == status assert "Do not resend" in notice["detail"] assert live.read_delivery_result(tmp_path, record["delivery_id"])["status"] == status def test_live_dm_wait_stops_at_its_deadline_while_the_owner_holds_the_receipt(tmp_path, monkeypatch, capsys): """A live owner that never claims (stalled consumer) or never settles still releases the runner.""" from tools import bot_live_delivery as live owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="3" * 32) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_MAX_SECONDS", 0.1) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: owner) assert bot_mode_dm._wait_live_dm(str(tmp_path), record["delivery_id"]) == 0 notice = json.loads(capsys.readouterr().out) assert notice["status"] == "queued" assert "Do not resend" in notice["detail"] # ── plaintext tempfile lifecycle ───────────────────────────────────────────── @pytest.mark.parametrize("stdin_file", [False, True]) @pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"]) def test_delivery_runner_keeps_file_for_child_then_unlinks(tmp_path, stdin_file, encoding): dm_file = tmp_path / "message with spaces.txt" dm_file.write_bytes("secret λ $(not shell)".encode(encoding)) observed = tmp_path / "observed.txt" child = tmp_path / "child.py" child.write_text( textwrap.dedent( """\ import pathlib import sys source = sys.stdin if sys.argv[1] == "-" else open(sys.argv[1], encoding="utf-8-sig") with source: pathlib.Path(sys.argv[2]).write_text(source.read(), encoding="utf-8") """ ), encoding="utf-8", ) source_arg = "-" if stdin_file else str(dm_file) returncode = bot_mode_dm._run_delivery( [sys.executable, str(child), source_arg, str(observed)], str(dm_file), stdin_file=stdin_file, ) assert returncode == 0 assert observed.read_text(encoding="utf-8") == "secret λ $(not shell)" assert not dm_file.exists() def test_delivery_runner_preserves_child_failure_and_unlinks(tmp_path): dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") child = tmp_path / "fail.py" child.write_text( "import pathlib, sys\n" "assert pathlib.Path(sys.argv[-1]).read_text(encoding='utf-8') == 'secret'\n" "raise SystemExit(7)\n", encoding="utf-8", ) returncode = bot_mode_dm._run_delivery( [sys.executable, str(child)], str(dm_file), stdin_file=False ) assert returncode == 7 assert not dm_file.exists() def test_delivery_runner_surfaces_live_owner_refusal(tmp_path, capsys): """#100523: the CLI's single-owner lease refusal is a delivery FAILURE the sender can read, not a raw exit-1 with the payload silently gone.""" dm_file = tmp_path / "message.txt" dm_file.write_text("hi", encoding="utf-8") child = tmp_path / "owned.py" child.write_text( "import sys\n" "print('Session abc already has a live owner (desktop, pid 1).', file=sys.stderr)\n" "raise SystemExit(1)\n", encoding="utf-8", ) returncode = bot_mode_dm._run_delivery( [sys.executable, str(child), "-p", "ops"], str(dm_file), stdin_file=False ) assert returncode == 1 payload = json.loads(capsys.readouterr().out) assert payload["reason"] == "target_busy" def test_local_turn_reemits_empty_stdout_for_a_bare_silence_marker(tmp_path, capsys): """#110782: the one-shot ``hermes chat -c "Bot Chat"`` transport applies the gateway's silence rule — a successful bare marker reaches the sender as "", prose stays verbatim.""" dm_file = tmp_path / "message.txt" dm_file.write_text("thanks, bye", encoding="utf-8") child = tmp_path / "quiet.py" child.write_text("import sys\nprint(sys.argv[1])\n", encoding="utf-8") assert bot_mode_dm._run_local_turn([sys.executable, str(child), "NO_REPLY"], str(dm_file)) == 0 assert capsys.readouterr().out == "" prose = "The NO_REPLY marker means do not answer." assert bot_mode_dm._run_local_turn([sys.executable, str(child), prose], str(dm_file)) == 0 assert capsys.readouterr().out.strip() == prose def test_query_file_delivery_closes_stdin_for_initial_attempt_and_retry( tmp_path, monkeypatch ): dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") calls = [] responses = [ subprocess.CompletedProcess([], 1, stdout="", stderr="HTTP 429 rate limit"), subprocess.CompletedProcess([], 0, stdout="", stderr=""), ] def fake_run(argv, **kwargs): calls.append((argv, kwargs)) return responses.pop(0) monkeypatch.setattr(subprocess, "run", fake_run) returncode = bot_mode_dm._run_delivery( ["hermes", "-p", "researcher"], str(dm_file), stdin_file=False ) assert returncode == 0 assert len(calls) == 2 assert [kwargs["stdin"] for _argv, kwargs in calls] == [ subprocess.DEVNULL, subprocess.DEVNULL, ] assert not dm_file.exists() @pytest.mark.parametrize("mode, author", [ ("stdin", {"id": "bot:eri-mac.local/coder", "name": "coder", "is_bot": True}), ("query-file", {"id": "bot:coder", "name": "coder", "is_bot": True}), ("query-file", None), ], ids=["stdin", "query-file", "no author"]) def test_delivery_main_child_env_carries_only_the_argv_author(tmp_path, monkeypatch, mode, author): """The ``--author`` payload becomes HERMES_TURN_AUTHOR on the child. Without it the runner drops the variable it inherited from the sending bot's own turn instead of passing it on as the recipient's author.""" from agent.turn_author import TURN_AUTHOR_ENV dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") calls = [] def fake_run(argv, **kwargs): calls.append((argv, kwargs)) return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setenv("HERMES_DM_TEST_MARKER", "kept") monkeypatch.setenv(TURN_AUTHOR_ENV, json.dumps({"id": "bot:previous", "name": "previous", "is_bot": True})) author_args = ["--author", json.dumps(author)] if author else [] returncode = bot_mode_dm._delivery_main( ["--run-delivery", *author_args, mode, str(dm_file), "hermes", "-p", "researcher"]) assert returncode == 0 [(argv, kwargs)] = calls assert argv[:3] == ["hermes", "-p", "researcher"] assert kwargs["env"]["HERMES_DM_TEST_MARKER"] == "kept" assert (json.loads(kwargs["env"][TURN_AUTHOR_ENV]) if TURN_AUTHOR_ENV in kwargs["env"] else None) == author assert not dm_file.exists() def test_real_delivery_command_round_trip_carries_author(tmp_path): """Through a real subprocess, the runner argv built by ``_delivery_command`` sets HERMES_TURN_AUTHOR on the child.""" dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") observed = tmp_path / "observed.txt" child = tmp_path / "child.py" child.write_text( "import os, pathlib, sys\n" "pathlib.Path(sys.argv[1]).write_text(os.environ.get('HERMES_TURN_AUTHOR', 'unset'), encoding='utf-8')\n", encoding="utf-8", ) author = {"id": "bot:default", "name": "hermes", "is_bot": True} command = bot_mode_dm._delivery_command( [sys.executable, str(child), str(observed)], str(dm_file), stdin_file=False, author=author ) result = subprocess.run(shlex.split(command), check=False) assert result.returncode == 0 assert json.loads(observed.read_text(encoding="utf-8")) == author assert not dm_file.exists() def test_delivery_main_maps_launch_exception_to_one_and_unlinks(tmp_path, monkeypatch): dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") def boom(*args, **kwargs): raise RuntimeError("child launch failed") monkeypatch.setattr(subprocess, "run", boom) assert ( bot_mode_dm._delivery_main( ["--run-delivery", "query-file", str(dm_file), "missing-transport"] ) == 1 ) assert not dm_file.exists() def test_local_turn_decodes_utf8_reply_without_locale_default(tmp_path, monkeypatch, capsys): child = tmp_path / "reply.py" child.write_text( "import sys\n" "sys.stdout.buffer.write('réponse 世界'.encode('utf-8'))\n" "sys.stderr.buffer.write('diagnostic café'.encode('utf-8'))\n", encoding="utf-8", ) monkeypatch.setattr(subprocess, "_text_encoding", lambda: "ascii") assert bot_mode_dm._run_local_turn([sys.executable, str(child)], str(tmp_path / "unused")) == 0 captured = capsys.readouterr() assert captured.out == "réponse 世界" assert captured.err == "diagnostic café" @pytest.mark.parametrize("stdin_file", [False, True]) def test_real_delivery_command_round_trip(tmp_path, stdin_file): dm_file = tmp_path / "message with spaces.txt" dm_file.write_text("secret λ\nsecond line", encoding="utf-8") observed = tmp_path / "observed with spaces.txt" child = tmp_path / "child with spaces.py" child.write_text( "import pathlib, sys\n" "source = sys.stdin if sys.argv[1] == '-' else open(sys.argv[1], encoding='utf-8')\n" "with source:\n" " pathlib.Path(sys.argv[2]).write_text(source.read(), encoding='utf-8')\n", encoding="utf-8", ) source_arg = "-" if stdin_file else str(dm_file) command = bot_mode_dm._delivery_command( [sys.executable, str(child), source_arg, str(observed)], str(dm_file), stdin_file=stdin_file, ) result = subprocess.run(shlex.split(command), check=False) assert result.returncode == 0 assert observed.read_text(encoding="utf-8") == "secret λ\nsecond line" assert not dm_file.exists() @pytest.mark.platforms("windows") def test_delivery_command_round_trip_through_windows_local_shell(tmp_path): """Native runner paths must survive the Git Bash process boundary.""" from tools.environments.local import _find_shell dm_file = tmp_path / "message with spaces.txt" dm_file.write_text("secret", encoding="utf-8") observed = tmp_path / "observed with spaces.txt" child = tmp_path / "child with spaces.py" child.write_text( "import pathlib, sys\n" "pathlib.Path(sys.argv[1]).write_text('started', encoding='utf-8')\n", encoding="utf-8", ) command = bot_mode_dm._delivery_command( [sys.executable, str(child), str(observed)], str(dm_file), stdin_file=False, ) result = subprocess.run( [_find_shell(), "-lic", command], check=False, capture_output=True, text=True, ) assert result.returncode == 0, result.stderr or result.stdout assert observed.read_text(encoding="utf-8") == "started" assert not dm_file.exists() @pytest.mark.parametrize( ("terminal_result", "raises"), [ (json.dumps({"error": "rejected"}), False), ("not json", False), (None, True), ], ) def test_spawn_failure_unlinks_untransferred_file( tmp_path, monkeypatch, terminal_result, raises ): dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") import tools.terminal_tool as terminal_tool_module def fail_spawn(command, **kwargs): assert dm_file.exists() if raises: raise RuntimeError("spawn failed") return terminal_result monkeypatch.setattr(terminal_tool_module, "terminal_tool", fail_spawn) result = json.loads( bot_mode_dm._spawn_delivery( "unused", "@researcher", dm_file=str(dm_file), task_id=None, agent=None ) ) assert "error" in result assert not dm_file.exists() def test_successful_spawn_transfers_cleanup_to_runner(tmp_path, monkeypatch): dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") import tools.terminal_tool as terminal_tool_module def launched(command, **kwargs): assert dm_file.exists() return json.dumps({"session_id": "proc_test1234"}) monkeypatch.setattr(terminal_tool_module, "terminal_tool", launched) result = json.loads( bot_mode_dm._spawn_delivery( "unused", "@researcher", dm_file=str(dm_file), task_id=None, agent=None ) ) assert result["status"] == "queued" assert dm_file.exists(), "the parent must not delete before the background runner reads" def test_write_dm_file_unlinks_partial_file_on_write_exception(tmp_path, monkeypatch): tmp_path / "partial.txt" real_mkstemp = bot_mode_dm.tempfile.mkstemp def fixed_mkstemp(**kwargs): kwargs["dir"] = tmp_path return real_mkstemp(**kwargs) class BrokenWriter: def __enter__(self): return self def __exit__(self, *exc_info): return False def write(self, content): raise OSError("disk full") monkeypatch.setattr(bot_mode_dm.tempfile, "mkstemp", fixed_mkstemp) monkeypatch.setattr(bot_mode_dm.os, "fdopen", lambda *args, **kwargs: BrokenWriter()) with pytest.raises(OSError, match="disk full"): bot_mode_dm._write_dm_file("secret") assert list(tmp_path.glob("dm-*.txt")) == [] @pytest.mark.platforms("linux") def test_dm_dir_is_private_and_uid_scoped_on_posix(tmp_path, monkeypatch): monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) dm_dir = bot_mode_dm._dm_dir() if hasattr(os, "getuid"): assert dm_dir.name == f"{bot_mode_dm._DM_DIR_NAME}-{os.getuid()}" else: assert dm_dir.name == bot_mode_dm._DM_DIR_NAME assert dm_dir.stat().st_mode & 0o777 == 0o700 @pytest.mark.platforms("linux") def test_dm_dir_repairs_restrictive_owner_mode(tmp_path, monkeypatch): monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) uid = os.getuid() if hasattr(os, "getuid") else None dirname = f"{bot_mode_dm._DM_DIR_NAME}-{uid}" if uid is not None else bot_mode_dm._DM_DIR_NAME dm_dir = tmp_path / dirname dm_dir.mkdir(mode=0o500) dm_dir.chmod(0o500) assert bot_mode_dm._dm_dir() == dm_dir assert dm_dir.stat().st_mode & 0o777 == 0o700 @pytest.mark.skipif(not hasattr(os, "getuid"), reason="POSIX ownership contract") def test_dm_dir_rejects_precreated_symlink(tmp_path, monkeypatch): target = tmp_path / "attacker-controlled" target.mkdir() expected = tmp_path / f"{bot_mode_dm._DM_DIR_NAME}-{os.getuid()}" expected.symlink_to(target, target_is_directory=True) monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) with pytest.raises(PermissionError, match="not a directory"): bot_mode_dm._dm_dir() def test_cleanup_sweeps_stale_live_intents_and_keeps_fresh_ones(tmp_path, monkeypatch): """``.live.json`` holds the DM plaintext and outlives its runner for retries; the housekeeping sweep must reap the orphans like it reaps the dm files themselves.""" import os monkeypatch.setattr(bot_mode_dm, "_dm_dir", lambda: tmp_path) stale = tmp_path / "dm-old.txt.live.json" stale.write_text("{}", encoding="utf-8") os.utime(stale, (1, 1)) fresh = tmp_path / "dm-new.txt.live.json" fresh.write_text("{}", encoding="utf-8") assert bot_mode_dm.cleanup_bot_dm_cache() >= 1 assert not stale.exists() assert fresh.exists() def test_settled_live_wait_unlinks_the_intent_but_a_pending_one_keeps_it(tmp_path, monkeypatch, capsys): from tools import bot_live_delivery as live dm_file = tmp_path / "dm-x.txt" dm_file.write_text("secret plaintext", encoding="utf-8") intent = tmp_path / "dm-x.txt.live.json" intent.write_text("{}", encoding="utf-8") owner = dict(profile_home=str(tmp_path), session_id="bot", lease_id="lease", live_session_id="live") record = live.deliver_to_live_owner(tmp_path, owner, "ping", delivery_id="b" * 32) monkeypatch.setattr(bot_mode_dm, "_LIVE_WAIT_SECONDS", 0.01) monkeypatch.setattr(live, "_POLL_SECONDS", 0.01) monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: owner) outcomes = [] waiting = threading.Thread(target=lambda: outcomes.append(bot_mode_dm._wait_live_dm( str(tmp_path), record["delivery_id"], dm_file=dm_file)), daemon=True) waiting.start() time.sleep(0.05) pending_kept = waiting.is_alive() and intent.exists() and dm_file.exists() claimed = live.claim_pending_delivery(tmp_path, owner) assert claimed is not None live.complete_delivery(tmp_path, claimed["delivery_id"], status="settled", reply="ok") waiting.join(timeout=2) assert pending_kept, "a pending delivery keeps its intent while the runner waits" assert not waiting.is_alive() assert outcomes == [0] assert not intent.exists() assert not dm_file.exists(), "the dm .txt holds the same plaintext as the settled intent" def test_pending_approval_spawn_names_the_approval_and_reclaims_the_dm_file(tmp_path, monkeypatch): """terminal_tool's approval gate answers pending_approval with an EMPTY error and no session_id; a local delivery must say the runner needs approval (nothing was sent), not blame the spawn.""" dm_file = tmp_path / "message.txt" dm_file.write_text("secret", encoding="utf-8") import tools.terminal_tool as terminal_tool_module pending = terminal_tool_module._error_json("", status="pending_approval", approval_pending=True, command="python3 runner", description="command flagged") monkeypatch.setattr(terminal_tool_module, "terminal_tool", lambda command, **kwargs: pending) result = json.loads(bot_mode_dm._spawn_delivery("unused", "@researcher", dm_file=str(dm_file), task_id=None, agent=None)) assert "approval" in result["error"] assert not dm_file.exists() def test_relay_waiter_that_cannot_start_reports_queued_not_failed(tmp_path, monkeypatch): """The relay envelope is queued before the reply waiter spawns and the Desktop drains it on its own: a waiter that cannot start is a lost wake-up, not a failed delivery (a hard error makes the sender resend and deliver twice).""" from tools import bot_relay root = tmp_path / ".hermes" (root / "profiles" / "default").mkdir(parents=True) bot_relay.write_remote_roster(root, [{"profile": "researcher", "handle": "researcher", "connection_id": "laptop-1", "connection_label": "laptop"}]) import tools.terminal_tool as terminal_tool_module pending = terminal_tool_module._error_json("", status="pending_approval", approval_pending=True, command="python3 waiter", description="command flagged") monkeypatch.setattr(terminal_tool_module, "terminal_tool", lambda command, **kwargs: pending) result = json.loads(bot_mode_dm._try_relay_delivery(root, "researcher", "hello", "default", task_id=None, agent=None)) assert result["status"] == "queued" assert "error" not in result assert "approval" in result["notification_error"] assert list((bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR).glob("*.json")), "envelope still queued" def test_ack_names_poll_return_path_when_session_cannot_receive_completions(tmp_path, monkeypatch): """#101142: on a non-push sender surface (api_server) terminal_tool refuses the ``notify_on_complete`` promise, so the reply can never be injected later. The ack must not promise a completion notification; it names the surface-supported return path instead.""" import tools.terminal_tool as terminal_tool_module monkeypatch.setattr(terminal_tool_module, "terminal_tool", lambda command, **kw: json.dumps({ "output": "Background process started", "session_id": "proc_np1", "notify_on_complete": False, "notify_unsupported": "poll"})) home = _managed_home(tmp_path, teammates=("researcher",)) result = json.loads(bot_mode_dm.message_agent_tool( target="researcher", message="hi", agent=_FakeAgent(home, title="Bot Chat"))) assert result["status"] == "queued" assert result["reply_delivery"] == "poll" assert "proc_np1" in result["detail"] def test_live_owner_ack_carries_the_poll_return_path_when_session_cannot_receive_completions(tmp_path, monkeypatch): """#101142 sibling: a live-owner (Desktop) target still runs the same tracked runner whose stdout carries the reply. On a non-push sender the live-owner ack must propagate ``reply_delivery="poll"`` and the wait instruction instead of 'finish your turn'.""" from tools import bot_live_delivery as live import tools.terminal_tool as terminal_tool_module home = _managed_home(tmp_path) target = home / "profiles" / "researcher" owner = dict(profile_home=str(target), session_id="bot", lease_id="lease", live_session_id="live") monkeypatch.setattr(live, "find_canonical_live_owner", lambda h: owner if Path(h) == target else None) monkeypatch.setattr(bot_mode_dm, "_dm_dir", lambda: tmp_path) monkeypatch.setattr(terminal_tool_module, "terminal_tool", lambda command, **kw: json.dumps({ "output": "Background process started", "session_id": "proc_np2", "notify_on_complete": False})) result = json.loads(bot_mode_dm.message_agent_tool("researcher", "hello", agent=_FakeAgent(home))) assert result["status"] == "queued" assert result["process_id"] == "proc_np2" assert result["reply_delivery"] == "poll" assert "proc_np2" in result["detail"] def test_poll_reply_is_persisted_as_a_delivery_row_when_the_runner_exits(tmp_path, monkeypatch): """#101142 durable leg: with no completion notification the sender may end its turn without polling; the tracked runner's exit must still land the reply in the sender's session transcript as a DELIVERY row (``display_kind=process_complete``), so nothing is silently lost.""" import tools.terminal_tool as terminal_tool_module from tools.process_registry import process_registry reply = json.dumps({"status": "settled", "reply": "PAYLOAD_SENTINEL_42", "delivery_id": "d1"}) procs = [] def fake_terminal_tool(command, **kw): popen = subprocess.Popen([sys.executable, "-c", f"import json; print({reply!r})"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) procs.append(process_registry.adopt_local(popen, command=command, cwd=str(tmp_path), notify_on_complete=False)) return json.dumps({"output": "Background process started", "session_id": procs[-1].id, "notify_on_complete": False}) monkeypatch.setattr(terminal_tool_module, "terminal_tool", fake_terminal_tool) home = _managed_home(tmp_path, teammates=("researcher",)) agent = _FakeAgent(home, title="Bot Chat") rows = [] agent._session_db.append_message = lambda session_id, role, **kw: rows.append((session_id, role, kw)) or 1 result = json.loads(bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent)) assert result["reply_delivery"] == "poll" deadline = time.monotonic() + 10 while not rows and time.monotonic() < deadline: time.sleep(0.05) assert len(rows) == 1 session_id, role, kw = rows[0] assert (session_id, role) == ("sess-1", "user") assert kw["display_kind"] == "process_complete" assert "PAYLOAD_SENTINEL_42" in kw["content"] assert procs[0].id in kw["content"] def test_local_turn_survives_undecodable_transport_output(tmp_path, capsys): """A transport that exits 0 while printing a non-UTF-8 byte must still deliver. A strict decode raised UnicodeDecodeError inside subprocess.run — a ValueError, so no handler caught it and the delivery crashed instead of re-emitting the transport's streams (stdout is the reply text the completion notification carries back). """ dm_file = tmp_path / "dm.txt" dm_file.write_text("hello", encoding="utf-8") argv = [sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'reply \\377')"] assert bot_mode_dm._run_local_turn(argv, str(dm_file)) == 0 assert "reply" in capsys.readouterr().out def test_local_turn_relays_utf8_reply_under_a_gbk_default_codec(tmp_path, monkeypatch, capsys): """#83851: the transport is a Hermes CLI child, which always writes UTF-8 stdio. Decoding it with the host's default codec (cp936 on zh-CN Windows) crashed or garbled the reply; it must round-trip.""" dm_file = tmp_path / "dm.txt" dm_file.write_text("hello", encoding="utf-8") reply = "✅ 已完成…" argv = [sys.executable, "-c", f"import sys; sys.stdout.buffer.write({reply.encode('utf-8')!r})"] # subprocess resolves an unspecified text-mode codec through _text_encoding() → locale.getencoding(); # patch that seam since run_tests.sh's PYTHONUTF8=1 short-circuits the locale lookup. monkeypatch.setattr(subprocess, "_text_encoding", lambda: "gbk") assert bot_mode_dm._run_local_turn(argv, str(dm_file)) == 0 assert reply in capsys.readouterr().out