Files
hermes-agent/tests/tools/test_bot_relay.py
teknium1 524c38a98a test: purge low-value tests, lane py16 (624 removed)
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
2026-09-23 03:15:26 -07:00

709 lines
31 KiB
Python

"""Tests: cross-connection bot relay (tools/bot_relay.py + message_agent route).
Connections ARE the peer set: every Desktop-connected gateway must be
message_agent-reachable. These tests pin the gateway-side plumbing —
roster validation, target resolution (incl. ambiguity), outbox claim
atomicity, reply write validation — and the two behavior contracts the
relay adds to message_agent:
- a target resolving against the Desktop-synced relay roster is queued as
an envelope and acknowledged like any DM (fire-and-forget, waiter spawned);
- the legacy-SOUL dedupe (empty protocol section) NO LONGER strips the tool:
the injection/execution gates key on managed-install, not section text.
"""
import json
import re
from pathlib import Path
import pytest
from tools import bot_relay
from tools.bot_mode_dm import (
MESSAGE_AGENT_TOOL_NAME,
ensure_message_agent_tool,
message_agent_tool,
)
@pytest.fixture()
def root(tmp_path):
return tmp_path
def _rows():
return [
{
"profile": "default",
"handle": "hermes",
"connection_id": "cloud-1",
"connection_label": "Hermes Cloud",
"title": "Moxie",
"description": "Main cloud agent",
},
{
"profile": "researcher",
"handle": "researcher",
"connection_id": "ssh-vps",
"connection_label": "VPS",
},
]
# ── roster ───────────────────────────────────────────────────────────────────
def test_roster_roundtrip_and_validation(root):
rows = _rows() + [
{"profile": "", "handle": "x", "connection_id": "c"}, # no profile
{"profile": "bad name!", "connection_id": "c"}, # bad charset
"not-a-dict",
{"profile": "default", "handle": "hermes", "connection_id": "cloud-1"}, # dupe
]
count = bot_relay.write_remote_roster(root, rows)
assert count == 2
back = bot_relay.read_remote_roster(root)
assert [r["profile"] for r in back] == ["default", "researcher"]
assert back[0]["title"] == "Moxie"
def test_roster_read_missing_and_corrupt(root):
assert bot_relay.read_remote_roster(root) == []
base = bot_relay.relay_root(root)
base.mkdir(parents=True)
(base / bot_relay.ROSTER_FILE).write_text("{corrupt", encoding="utf-8")
assert bot_relay.read_remote_roster(root) == []
def test_resolve_remote_target_forms(root):
bot_relay.write_remote_roster(root, _rows())
roster = bot_relay.read_remote_roster(root)
assert bot_relay.resolve_remote_target("researcher", roster)["connection_id"] == "ssh-vps"
assert bot_relay.resolve_remote_target("@hermes", roster)["profile"] == "default"
# profile name resolves too
assert bot_relay.resolve_remote_target("default", roster)["connection_id"] == "cloud-1"
# exact connection-qualified form
assert bot_relay.resolve_remote_target("hermes@cloud-1", roster)["profile"] == "default"
# profile@connection — the form Desktop's mention middleware annotates
# for remote bots (#97678); the UI alias form must not be required
assert bot_relay.resolve_remote_target("default@cloud-1", roster)["profile"] == "default"
assert bot_relay.resolve_remote_target("hermes@nope", roster) is None
assert bot_relay.resolve_remote_target("ghost", roster) is None
def test_resolve_ambiguous_handle_across_connections(root):
rows = _rows() + [
{"profile": "researcher", "handle": "researcher", "connection_id": "cloud-1"}
]
bot_relay.write_remote_roster(root, rows)
roster = bot_relay.read_remote_roster(root)
assert bot_relay.resolve_remote_target("researcher", roster) == "ambiguous"
match = bot_relay.resolve_remote_target("researcher@ssh-vps", roster)
assert match["connection_id"] == "ssh-vps"
forms = bot_relay.remote_target_forms(roster)
assert "researcher@ssh-vps" in forms and "researcher@cloud-1" in forms
assert "hermes" in forms # unique handle stays bare
# ── outbox / replies ─────────────────────────────────────────────────────────
def test_enqueue_claim_is_atomic_and_single_shot(root):
bot_relay.write_remote_roster(root, _rows())
roster = bot_relay.read_remote_roster(root)
target = bot_relay.resolve_remote_target("researcher", roster)
env = bot_relay.enqueue_envelope(
root, target=target, message="hi", sender_profile="work", sender_handle="work"
)
assert re.match(r"^[0-9a-f]{32}$", env["id"])
claimed = bot_relay.claim_pending_envelopes(root)
assert [e["id"] for e in claimed] == [env["id"]]
assert claimed[0]["target_connection"] == "ssh-vps"
assert claimed[0]["message"] == "hi"
# second drain: nothing (no double delivery)
assert bot_relay.claim_pending_envelopes(root) == []
def test_claim_skips_non_dict_envelope(root):
"""A parseable-but-non-object outbox file must not reach the Desktop consumer or
crash the claim sweep; the claim stays claimed so it is not re-queued."""
bot_relay.write_remote_roster(root, _rows())
roster = bot_relay.read_remote_roster(root)
target = bot_relay.resolve_remote_target("researcher", roster)
env = bot_relay.enqueue_envelope(
root, target=target, message="hi", sender_profile="work", sender_handle="work"
)
base = bot_relay.relay_root(root)
bad = base / bot_relay.OUTBOX_DIR / f"{'9' * 32}.json"
bad.write_text('"not an envelope"', encoding="utf-8")
claimed = bot_relay.claim_pending_envelopes(root)
assert [e["id"] for e in claimed] == [env["id"]]
assert not bad.exists() and (base / bot_relay.CLAIMED_DIR / bad.name).exists()
def test_write_reply_validates_envelope_id(root):
with pytest.raises(ValueError):
bot_relay.write_reply(root, "../../etc/passwd", reply="x")
path = bot_relay.write_reply(root, "a" * 32, reply="pong")
data = json.loads(Path(path).read_text(encoding="utf-8"))
assert data["reply"] == "pong" and not data["error"]
def test_write_reply_keeps_the_first_settled_reply_for_an_envelope(root):
"""Idempotent by envelope id: a re-offered delivery's second outcome (or a late duplicate) must
not displace the reply the waiter already read, so the answer never turns into an error."""
env_id = "a" * 32
first = bot_relay.write_reply(root, env_id, reply="the answer")
second = bot_relay.write_reply(root, env_id, error="target busy", reason="target_busy")
assert second == first
record = json.loads(first.read_text(encoding="utf-8"))
assert (record["reply"], record["error"], record["reason"]) == ("the answer", "", "")
def test_write_reply_reason_passthrough_and_classification(root):
# explicit reason is persisted verbatim
path = bot_relay.write_reply(root, "c" * 32, error="boom", reason="delivery_timeout")
data = json.loads(Path(path).read_text(encoding="utf-8"))
assert data["reason"] == "delivery_timeout" and data["error"] == "boom"
# no reason given → classified from error text
path = bot_relay.write_reply(root, "d" * 32, error="Error code: 429 - rate limit")
data = json.loads(Path(path).read_text(encoding="utf-8"))
assert data["reason"] == "provider_rate_limit"
# success reply carries an empty reason
path = bot_relay.write_reply(root, "e" * 32, reply="ok")
data = json.loads(Path(path).read_text(encoding="utf-8"))
assert data["reason"] == "" and data["reply"] == "ok"
def test_waiter_is_a_runner_entrypoint_the_approval_gate_lets_through(root):
"""The waiter is spawned through terminal_tool from the SENDER's turn. When a bot replies to a
teammate from its own one-shot delivery turn, that turn runs under ``approvals.single_query_mode``
(default ``deny``), and inline interpreter code is a flagged pattern — so the reply waiter was
refused exactly when bots talked to each other, and the reply never woke the sender."""
import shlex
from tools.approval import detect_dangerous_command
env = {"id": "b" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"}
cmd = bot_relay.waiter_command(root, env)
parts = shlex.split(cmd)
assert detect_dangerous_command(cmd)[0] is False, cmd
assert parts[1].endswith("bot_mode_dm.py") and parts[2] == "--wait-reply"
assert parts[3] == str(bot_relay.relay_root(root) / bot_relay.REPLIES_DIR / f"{'b' * 32}.json")
assert parts[4:] == ["@researcher on ssh-vps", str(bot_relay.REPLY_WAIT_SECONDS)]
def test_waiter_outlives_the_desktop_deliver_deadline():
"""The Desktop posts its timeout reply when RELAY_DELIVER_TIMEOUT_MS passes. A waiter that gave
up first left that reply, and any turn finishing after minute 15, in a file nobody read (#93911).
relay-deliver-budget.test.ts pins the TS constants against these Python ones.
The re-offer window (#111021) sits between the two: only past the Desktop's deadline — and the
gateway's own worst-case deliver hold, which ends before it — is a claimed envelope's silence
provably a dead Desktop rather than a slow turn, and the waiter must still be listening when the
ONE re-offered delivery hits its own Desktop deadline."""
live_hold_s = bot_relay.TURN_WAIT_SECONDS_FALLBACK + bot_relay.TURN_ATTEMPT_TIMEOUT_SECONDS * bot_relay.TURN_MAX_ATTEMPTS
desktop_budget_s = live_hold_s + bot_relay.DESKTOP_DELIVER_SETTLEMENT_MARGIN_SECONDS
assert bot_relay.DESKTOP_DELIVER_TIMEOUT_SECONDS == desktop_budget_s
assert bot_relay.REOFFER_AFTER_SECONDS > desktop_budget_s > live_hold_s
assert bot_relay.REPLY_WAIT_SECONDS > bot_relay.REOFFER_AFTER_SECONDS + desktop_budget_s
@pytest.mark.parametrize(
("reply_file", "expected_code", "expected_tokens"),
[
({"reply": "pong"}, 0, ["@researcher on ssh-vps", "pong"]),
({"reply": ""}, 0, ["@researcher on ssh-vps"]),
({"error": "turn failed", "reason": "provider_rate_limit"}, 1,
["@researcher on ssh-vps", "provider_rate_limit", "turn failed"]),
({"error": "turn failed"}, 1, ["@researcher on ssh-vps", "turn failed"]),
(None, 1, ["@researcher on ssh-vps", "0.3s"]),
],
ids=["reply", "empty-reply", "typed-error", "untyped-error", "gave-up"],
)
def test_waiter_prints_the_completion_notification_the_sender_wakes_on(root, capsys, reply_file, expected_code, expected_tokens):
"""The waiter's stdout IS the sender's completion notification: the reply, a typed failure the
sender can branch on without parsing prose (#93091), or an honest give-up that names the budget."""
from tools import bot_mode_dm
env = {"id": "d" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"}
reply_path = bot_relay.relay_root(root) / bot_relay.REPLIES_DIR / f"{env['id']}.json"
reply_path.parent.mkdir(parents=True, exist_ok=True)
if reply_file is not None:
reply_path.write_text(json.dumps(reply_file), encoding="utf-8")
code = bot_mode_dm._delivery_main(["--wait-reply", str(reply_path), "@researcher on ssh-vps", "0.3"])
out = capsys.readouterr().out
assert code == expected_code
assert all(token in out for token in expected_tokens), out
assert bot_mode_dm._delivery_main(["--wait-reply", str(reply_path)]) == 2
def test_roster_rejects_connection_id_outside_handle_charset(root):
bad = [
{"profile": "researcher", "handle": "researcher", "connection_id": "vps'); print(1)"},
{"profile": "researcher", "handle": "researcher", "connection_id": "foo'bar"},
{"profile": "researcher", "handle": "researcher", "connection_id": "ssh vps"},
{"profile": "researcher", "handle": "researcher", "connection_id": "a" * 65},
]
assert bot_relay.write_remote_roster(root, bad) == 0
good = {
"profile": "researcher",
"handle": "researcher",
"connection_id": "ssh-vps",
}
assert bot_relay.write_remote_roster(root, [good]) == 1
def test_hostile_roster_fields_ride_as_argv_data(root):
"""Envelope fields come from the Desktop-pushed roster — untrusted. They must never become
source text: the waiter takes them as argv, so a payload shaped like Python is a label."""
import shlex
import subprocess
inj = "x'); open(r'/tmp/pwned','w').write('pwned'); print('x"
env = {"id": "c" * 32, "target_handle": "researcher", "target_connection": inj}
cmd = bot_relay.waiter_command(root, env)
parts = shlex.split(cmd)
assert "-c" not in parts
assert parts[4] == f"@researcher on {inj}"
reply_path = bot_relay.relay_root(root) / bot_relay.REPLIES_DIR / f"{env['id']}.json"
reply_path.parent.mkdir(parents=True, exist_ok=True)
reply_path.write_text(json.dumps({"reply": "pong"}), encoding="utf-8")
proc = subprocess.run(parts, capture_output=True, text=True, timeout=30)
assert proc.returncode == 0 and proc.stdout.splitlines() == [f"Reply from @researcher on {inj}:", "pong"]
# ── message_agent integration: relay route + legacy-SOUL gate fix ───────────
import textwrap
def _managed_home(tmp_path, *, legacy_soul=False):
home = tmp_path / ".hermes"
home.mkdir(exist_ok=True)
d = home / "profiles" / "researcher"
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 legacy_soul:
(home / "SOUL.md").write_text(
"# Soul\n\n## Messaging other agents\nold shellout protocol\n",
encoding="utf-8",
)
return home
class _FakeDB:
def __init__(self, home, title):
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, title="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()
@pytest.fixture(autouse=True)
def _fresh_probe_cache():
from tools import bot_mode_probe
bot_mode_probe._reset_cache_for_tests()
yield
bot_mode_probe._reset_cache_for_tests()
def test_tool_injects_despite_legacy_soul_protocol(tmp_path):
"""A SOUL.md still carrying the plugin-appended protocol must not cost the TOOL (nor,
since load-time stripping, the live section)."""
from tools import bot_mode_probe
home = _managed_home(tmp_path, legacy_soul=True)
assert bot_mode_probe.get_bot_mode_protocol_section(home) != ""
agent = _FakeAgent(home)
assert ensure_message_agent_tool(agent) is True
assert [t["function"]["name"] for t in agent.tools] == [MESSAGE_AGENT_TOOL_NAME]
def test_relay_route_queues_envelope_and_spawns_waiter(tmp_path, monkeypatch):
home = _managed_home(tmp_path)
bot_relay.write_remote_roster(home, [
{"profile": "default", "handle": "hermes", "connection_id": "cloud-1",
"connection_label": "Hermes Cloud", "title": "Moxie"},
])
spawned = {}
def _fake_spawn(command, label, *, task_id, agent, **_):
spawned["command"] = command
spawned["label"] = label
return json.dumps({"status": "queued", "to": label})
monkeypatch.setattr("tools.bot_mode_dm._spawn_delivery", _fake_spawn)
agent = _FakeAgent(home)
out = json.loads(message_agent_tool(target="hermes", message="ping", agent=agent))
assert out.get("status") == "queued"
assert "Hermes Cloud" in spawned["label"]
# envelope landed in the outbox with attribution prefixed
pending = bot_relay.claim_pending_envelopes(home)
assert len(pending) == 1
assert pending[0]["target_connection"] == "cloud-1"
assert pending[0]["target_profile"] == "default"
assert pending[0]["message"].startswith("Message from 🤖 hermes (@hermes): ping")
# waiter watches this envelope's reply file
assert pending[0]["id"] in spawned["command"]
def test_relay_route_ambiguous_target_errors_with_forms(tmp_path, monkeypatch):
home = _managed_home(tmp_path)
bot_relay.write_remote_roster(home, [
{"profile": "scout", "handle": "scout", "connection_id": "cloud-1"},
{"profile": "scout", "handle": "scout", "connection_id": "ssh-vps"},
])
monkeypatch.setattr(
"tools.bot_mode_dm._spawn_delivery",
lambda *a, **k: json.dumps({"status": "queued"}),
)
agent = _FakeAgent(home)
out = json.loads(message_agent_tool(target="scout", message="hi", agent=agent))
assert "scout@cloud-1" in out.get("error", "") and "scout@ssh-vps" in out["error"]
# connection-qualified form goes through
out2 = json.loads(message_agent_tool(target="scout@ssh-vps", message="hi", agent=agent))
assert out2.get("status") == "queued"
def test_remote_default_is_addressable_by_its_title_slug(tmp_path, monkeypatch):
"""A remote ``default`` is ``@hermes`` like every gateway's own, so its Bot Mode title is the only
bare form that singles it out: ``message_agent`` accepts the slug, the prompt roster offers it
(never bare ``@hermes``, which is the LOCAL default), and the form does not depend on roster order."""
from tools import bot_mode_probe
home = _managed_home(tmp_path)
rows = [
{"profile": "default", "handle": "hermes", "connection_id": "vps-1", "title": "CoS Bot"},
{"profile": "default", "handle": "hermes", "connection_id": "cloud-1", "title": "Ops Bot"},
{"profile": "cos-bot", "handle": "cos-bot", "connection_id": "cloud-1", "title": "Other"},
]
monkeypatch.setattr("tools.bot_mode_dm._spawn_delivery", lambda *a, **k: json.dumps({"status": "queued"}))
agent = _FakeAgent(home)
for order in (rows, rows[::-1]):
bot_relay.write_remote_roster(home, order)
roster = bot_relay.read_remote_roster(home)
forms = dict(zip((r["connection_id"] + "/" + r["profile"] for r in roster),
bot_relay.remote_target_forms(roster, bot_mode_probe.local_taken_forms(home))))
# an exact handle beats a colliding title slug; the collided title falls back to the qualified form
assert forms == {"cloud-1/cos-bot": "cos-bot", "vps-1/default": "hermes@vps-1", "cloud-1/default": "ops-bot"}
assert bot_relay.resolve_remote_target("cos-bot", roster)["profile"] == "cos-bot"
out = json.loads(message_agent_tool(target="@ops-bot", message="ping", agent=agent))
assert out.get("status") == "queued"
[env] = bot_relay.claim_pending_envelopes(home)
assert (env["target_connection"], env["target_profile"]) == ("cloud-1", "default")
# a bare @hermes from the local default is the two remote defaults — offered under their reply-safe forms
err = json.loads(message_agent_tool(target="hermes", message="ping", agent=agent))["error"]
assert "hermes@vps-1" in err and "ops-bot" in err
section = bot_mode_probe.get_bot_mode_protocol_section(home)
assert "`@ops-bot`" in section and "`@hermes@vps-1`" in section and "- `@hermes` —" not in section
def test_protocol_section_lists_remote_teammates(tmp_path):
from tools import bot_mode_probe
home = _managed_home(tmp_path)
bot_relay.write_remote_roster(home, [
{"profile": "default", "handle": "hermes", "connection_id": "cloud-1",
"connection_label": "Hermes Cloud", "title": "Moxie"},
])
section = bot_mode_probe.get_bot_mode_protocol_section(home, force_refresh=True)
# Offered under its title slug: bare `@hermes` is THIS gateway's own default (#103731).
assert "`@moxie`" in section and "Hermes Cloud" in section
def test_capability_fingerprint_changes_with_relay_roster(tmp_path):
from tools import bot_mode_probe
home = _managed_home(tmp_path)
before = bot_mode_probe.capability_fingerprint(home)
bot_relay.write_remote_roster(home, [
{"profile": "default", "handle": "hermes", "connection_id": "cloud-1"},
])
after = bot_mode_probe.capability_fingerprint(home)
assert before != after # eternal Bot Chats refresh once on roster change
# ── stale artifact sweep (housekeeping contract) ─────────────────────────────
def test_cleanup_bot_relay_artifacts_sweeps_stale_plaintext(tmp_path, monkeypatch):
import os as _os
import time as _time
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
target = {"profile": "scout", "handle": "scout", "connection_id": "cloud-1",
"connection_label": "", "title": "", "description": ""}
stale_env = bot_relay.enqueue_envelope(
tmp_path, target=target, message="old secret",
sender_profile="default", sender_handle="hermes",
)
fresh_env = bot_relay.enqueue_envelope(
tmp_path, target=target, message="new secret",
sender_profile="default", sender_handle="hermes",
)
base = bot_relay.relay_root(tmp_path)
stale_reply = bot_relay.write_reply(tmp_path, stale_env["id"], reply="done")
old = _time.time() - bot_relay.STALE_AFTER_SECONDS - 1
_os.utime(base / bot_relay.OUTBOX_DIR / f"{stale_env['id']}.json", (old, old))
_os.utime(stale_reply, (old, old))
removed = bot_relay.cleanup_bot_relay_artifacts()
assert removed == 2
assert not (base / bot_relay.OUTBOX_DIR / f"{stale_env['id']}.json").exists()
assert not stale_reply.exists()
assert (base / bot_relay.OUTBOX_DIR / f"{fresh_env['id']}.json").exists()
def test_cleanup_bot_relay_artifacts_missing_dir_is_zero(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "nope"))
assert bot_relay.cleanup_bot_relay_artifacts() == 0
# ── #93091 item 2: offline fail-fast + drain-time TTL ────────────────────────
import os as _os2
import time as _time2
def _target(conn="cloud-1", profile="scout", handle="scout"):
return {"profile": profile, "handle": handle, "connection_id": conn,
"connection_label": "", "title": "", "description": ""}
def test_enqueue_fails_fast_when_row_explicitly_offline(root):
bot_relay.write_remote_roster(root, [
{"profile": "scout", "handle": "scout", "connection_id": "cloud-1",
"online": False},
])
roster = bot_relay.read_remote_roster(root)
assert roster[0]["online"] is False # additive field survives normalize
with pytest.raises(bot_relay.EnvelopeRefusedError) as ei:
bot_relay.enqueue_envelope(
root, target=roster[0], message="hi",
sender_profile="default", sender_handle="hermes",
)
assert ei.value.reason == "runtime_offline"
assert "offline" in str(ei.value)
# nothing was written to the outbox
outdir = bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR
assert not outdir.exists() or list(outdir.glob("*.json")) == []
def test_enqueue_fails_fast_when_target_absent_from_fresh_roster(root):
bot_relay.write_remote_roster(root, _rows()) # fresh, no 'scout' row
with pytest.raises(bot_relay.EnvelopeRefusedError) as ei:
bot_relay.enqueue_envelope(
root, target=_target(), message="hi",
sender_profile="default", sender_handle="hermes",
)
assert ei.value.reason == "runtime_offline"
def test_enqueue_fails_open_when_liveness_unknown(root):
# 1. no roster ever synced → unknown → enqueue
env = bot_relay.enqueue_envelope(
root, target=_target(), message="hi",
sender_profile="default", sender_handle="hermes",
)
assert (bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR / f"{env['id']}.json").exists()
# 2. stale roster missing the target → unknown → enqueue
bot_relay.write_remote_roster(root, _rows())
roster_path = bot_relay.relay_root(root) / bot_relay.ROSTER_FILE
old = _time2.time() - bot_relay.ROSTER_FRESH_SECONDS - 5
_os2.utime(roster_path, (old, old))
env2 = bot_relay.enqueue_envelope(
root, target=_target(), message="hi again",
sender_profile="default", sender_handle="hermes",
)
assert (bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR / f"{env2['id']}.json").exists()
# 3. fresh roster listing the target without an online flag → enqueue
bot_relay.write_remote_roster(root, _rows())
target = bot_relay.read_remote_roster(root)[1] # researcher@ssh-vps
env3 = bot_relay.enqueue_envelope(
root, target=target, message="hello",
sender_profile="default", sender_handle="hermes",
)
assert (bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR / f"{env3['id']}.json").exists()
def test_drain_expires_old_envelope_with_queued_expired_reply(root):
env = bot_relay.enqueue_envelope(
root, target=_target(), message="too late",
sender_profile="default", sender_handle="hermes",
)
base = bot_relay.relay_root(root)
out_path = base / bot_relay.OUTBOX_DIR / f"{env['id']}.json"
# backdate the envelope beyond the TTL
env["created_at"] = int(_time2.time()) - bot_relay.DEFAULT_ENVELOPE_TTL_SECONDS - 10
out_path.write_text(json.dumps(env), encoding="utf-8")
claimed = bot_relay.claim_pending_envelopes(root)
assert claimed == [] # not delivered
assert not out_path.exists() # expired outbox file removed
reply = json.loads(
(base / bot_relay.REPLIES_DIR / f"{env['id']}.json").read_text(encoding="utf-8")
)
assert reply["reason"] == "queued_expired"
assert reply["error"]
assert not reply["reply"]
def test_the_outbox_is_claimed_oldest_first(root):
"""Two DMs from one sender to one agent must arrive in the order they were sent. The Desktop
delivers each target's claimed envelopes in the order this list gives them, one turn at a
time, so the claim IS the delivery order — and sorting by filename ordered them by
``uuid4().hex``. The names here are forced into the reverse of the send order to pin that
deterministically, which random ids reproduce half the time."""
first = bot_relay.enqueue_envelope(
root, target=_target(), message="do this first",
sender_profile="default", sender_handle="hermes",
)
second = bot_relay.enqueue_envelope(
root, target=_target(), message="then this",
sender_profile="default", sender_handle="hermes",
)
outbox = bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR
now = _time2.time()
for env, name, sent_at in ((first, "f" * 32, now - 2), (second, "0" * 32, now - 1)):
path = outbox / f"{name}.json"
(outbox / f"{env['id']}.json").rename(path)
_os2.utime(path, (sent_at, sent_at))
claimed = bot_relay.claim_pending_envelopes(root)
assert [e["id"] for e in claimed] == [first["id"], second["id"]]
assert [e["message"] for e in claimed] == ["do this first", "then this"]
def test_drain_delivers_fresh_envelope_under_ttl(root):
env = bot_relay.enqueue_envelope(
root, target=_target(), message="on time",
sender_profile="default", sender_handle="hermes",
)
claimed = bot_relay.claim_pending_envelopes(root)
assert [e["id"] for e in claimed] == [env["id"]]
# no spurious expiry reply for a delivered envelope
base = bot_relay.relay_root(root)
assert not (base / bot_relay.REPLIES_DIR / f"{env['id']}.json").exists()
def test_drain_ttl_zero_disables_expiry(root, monkeypatch):
monkeypatch.setattr(bot_relay, "_envelope_ttl_seconds", lambda: 0)
env = bot_relay.enqueue_envelope(
root, target=_target(), message="never expires",
sender_profile="default", sender_handle="hermes",
)
base = bot_relay.relay_root(root)
out_path = base / bot_relay.OUTBOX_DIR / f"{env['id']}.json"
env["created_at"] = int(_time2.time()) - 10 * 3600
out_path.write_text(json.dumps(env), encoding="utf-8")
claimed = bot_relay.claim_pending_envelopes(root)
assert [e["id"] for e in claimed] == [env["id"]]
def test_invalid_ttl_config_falls_back_instead_of_breaking_drain(monkeypatch):
monkeypatch.setattr(bot_relay, "_bot_mode_cfg", lambda *args, **kwargs: "not-a-number")
assert bot_relay._envelope_ttl_seconds() == bot_relay.DEFAULT_ENVELOPE_TTL_SECONDS
def test_message_agent_surfaces_runtime_offline_refusal(tmp_path, monkeypatch):
home = _managed_home(tmp_path)
bot_relay.write_remote_roster(home, [
{"profile": "default", "handle": "hermes", "connection_id": "cloud-1",
"connection_label": "Hermes Cloud", "online": False},
])
monkeypatch.setattr(
"tools.bot_mode_dm._spawn_delivery",
lambda *a, **k: json.dumps({"status": "queued"}),
)
agent = _FakeAgent(home)
out = json.loads(message_agent_tool(target="hermes", message="ping", agent=agent))
assert out.get("reason") == "runtime_offline"
assert "offline" in out.get("error", "")
# fail-fast means no envelope was queued
assert bot_relay.claim_pending_envelopes(home) == []
# ── delivery turn author (HERMES_TURN_AUTHOR on the recipient turn) ──────────
def test_delivery_turn_author_from_envelope_sender_fields():
author = bot_relay.delivery_turn_author("ops", "ops-bot")
assert author == {"id": "bot:ops", "name": "ops-bot", "is_bot": True}
# The display name falls back to the profile; the id never comes from the handle.
assert bot_relay.delivery_turn_author("ops", "") == {"id": "bot:ops", "name": "ops", "is_bot": True}
assert bot_relay.delivery_turn_author("", "ops-bot") is None
assert bot_relay.delivery_turn_author(None, None) is None
def test_delivery_turn_author_qualifies_a_remote_sender_by_its_connection():
"""A relayed DM always crosses gateways, so the sender's connection id is part of the author id, ``local``
included; the recipient's own ``ops`` is the only bare ``bot:ops``."""
remote = bot_relay.delivery_turn_author("ops", "ops-bot", "cloud-1")
assert remote == {"id": "bot:cloud-1/ops", "name": "ops-bot", "is_bot": True}
assert bot_relay.delivery_turn_author("ops", "ops-bot", "local") == {"id": "bot:local/ops", "name": "ops-bot", "is_bot": True}
# An older Desktop that sends no connection id still yields an author.
assert bot_relay.delivery_turn_author("ops", "ops-bot", "") == {"id": "bot:ops", "name": "ops-bot", "is_bot": True}
def test_delivery_env_carries_only_the_given_author(monkeypatch):
"""The dispatcher's own HERMES_TURN_AUTHOR never reaches the child: dropped without an author, replaced with one."""
from agent.turn_author import TURN_AUTHOR_ENV
monkeypatch.setenv("HERMES_RELAY_TEST_MARKER", "kept")
monkeypatch.setenv(TURN_AUTHOR_ENV, json.dumps({"id": "bot:previous", "name": "previous", "is_bot": True}))
monkeypatch.setenv("HERMES_SESSION_KEY", "session-A")
monkeypatch.setenv("HERMES_UI_SESSION_ID", "ui-A")
monkeypatch.setenv("HERMES_SESSION_ID", "session-A")
monkeypatch.setenv("HERMES_SESSION_PROFILE", "profile-A")
# A session-* knob, not identity: stripping it would break the child's watcher tuning.
monkeypatch.setenv("HERMES_SESSION_STALL_TIMEOUT", "97")
assert TURN_AUTHOR_ENV not in bot_relay.delivery_env(None)
env = bot_relay.delivery_env(bot_relay.delivery_turn_author("ops", "ops"))
assert json.loads(env[TURN_AUTHOR_ENV]) == {"id": "bot:ops", "name": "ops", "is_bot": True}
assert env["HERMES_RELAY_TEST_MARKER"] == "kept"
assert "HERMES_SESSION_KEY" not in env
assert "HERMES_UI_SESSION_ID" not in env
assert "HERMES_SESSION_ID" not in env
assert "HERMES_SESSION_PROFILE" not in env
assert env["HERMES_SESSION_STALL_TIMEOUT"] == "97"