Files
hermes-agent/tests/tools/test_bot_relay.py
teknium1 250bdcc311 fix(bot-mode): message_agent CLI-runner ack is queued + delivery_id like the other branches
Review finding on #114941: the CLI-runner branch of `_spawn_delivery` still
answered `status: sent` with only a `process_id`, while the live-owner branch
answers `queued`/`claimed` + `delivery_id` and the relay branch `queued`. One
tool, three vocabularies; `sent` also reads as a delivery receipt, which is the
misreading this PR exists to remove.

`_spawn_delivery` now returns `status: queued`, a `delivery_id` and the
`process_id`. For CLI-runner deliveries the id is the same sha256 of the DM
file path the runner pins when it admits to a live owner (`_dm_delivery_id`,
replacing three inline copies), so the ack and any live receipt correlate; the
relay branch passes the envelope id (also on its notification_error ack).
`sent_at` becomes `queued_at`. Tool schema, Bot Mode user guide and the
assertions in tests/tools follow; two invariant tests pin the CLI-runner and
relay ids (red before, green after).
2026-09-18 10:39:50 -07:00

677 lines
28 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_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_command_quotes_and_targets_reply_file(root):
env = {"id": "b" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"}
cmd = bot_relay.waiter_command(root, env)
assert ("b" * 32) in cmd and "-c" in cmd
assert "rm -rf" not in cmd # sanity: single quoted -c payload
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."""
desktop_budget_s = (
bot_relay.TURN_WAIT_SECONDS_FALLBACK
+ bot_relay.TURN_ATTEMPT_TIMEOUT_SECONDS * bot_relay.TURN_MAX_ATTEMPTS
+ bot_relay.DESKTOP_DELIVER_SETTLEMENT_MARGIN_SECONDS
)
assert bot_relay.DESKTOP_DELIVER_TIMEOUT_SECONDS == desktop_budget_s
assert bot_relay.REPLY_WAIT_SECONDS > desktop_budget_s
def test_waiter_give_up_message_states_the_real_budget(root):
import shlex
env = {"id": "d" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"}
parts = shlex.split(bot_relay.waiter_command(root, env))
code = parts[parts.index("-c") + 1]
assert f"deadline = time.time() + {bot_relay.REPLY_WAIT_SECONDS}\n" in code
assert f"within {bot_relay.REPLY_WAIT_SECONDS}s" in code
assert "within 900s" not in code
def test_waiter_picks_up_reply_within_a_sub_second_cadence(root):
"""The reply file is written once; the waiter must notice it fast, not
on a multi-second sleep (dead air the sender's completion notification
inherits on every cross-machine reply)."""
import shlex
import subprocess
import threading
import time
env = {"id": "c" * 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)
def write_reply():
time.sleep(0.3)
reply_path.write_text(json.dumps({"reply": "pong"}), encoding="utf-8")
threading.Thread(target=write_reply, daemon=True).start()
started = time.monotonic()
proc = subprocess.run(shlex.split(bot_relay.waiter_command(root, env)), capture_output=True, text=True, timeout=10)
elapsed = time.monotonic() - started
assert proc.returncode == 0 and "pong" in proc.stdout
assert elapsed < 1.5, f"waiter took {elapsed:.2f}s to notice a reply written at 0.3s"
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_waiter_command_repr_encodes_hostile_connection_id(root):
import ast
import shlex
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)
code = parts[parts.index("-c") + 1]
compile(code, "<waiter>", "exec")
tree = ast.parse(code)
opens = [
n
for n in ast.walk(tree)
if isinstance(n, ast.Call)
and isinstance(n.func, ast.Name)
and n.func.id == "open"
]
# Only json.load(open(p, ...)) is a real open(); the payload must stay data.
assert len(opens) == 1
# A quote in the id used to SyntaxError the waiter. It must compile.
quoted = bot_relay.waiter_command(
root,
{"id": "a" * 32, "target_handle": "h", "target_connection": "foo'bar"},
)
qcode = shlex.split(quoted)[shlex.split(quoted).index("-c") + 1]
compile(qcode, "<waiter-quote>", "exec")
# ── 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_unknown_target_error_mentions_connected_machines(tmp_path):
home = _managed_home(tmp_path)
agent = _FakeAgent(home)
out = json.loads(message_agent_tool(target="ghost", message="hi", agent=agent))
assert "connected machine" in out.get("error", "")
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)
assert "OTHER connected machines" in section
assert "`@hermes` — on Hermes Cloud — Moxie" 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 "expired" in reply["error"] and "NOT delivered" in 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_ttl_config_read_is_lazy_and_defensive(monkeypatch):
import builtins
real_import = builtins.__import__
def _boom(name, *a, **k):
if name.startswith("hermes_cli"):
raise ImportError("config unavailable")
return real_import(name, *a, **k)
monkeypatch.setattr(builtins, "__import__", _boom)
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"