test_live_dm_runner_retry_never_reexecutes_failed_claim claimed the queued DM from a helper thread after a fixed 50 ms sleep. On a loaded runner _run_delivery had not queued it yet, the claim returned None, the thread's assert died silently, and _run_delivery waited forever on the mocked still-live owner (it waits while the owner lives, by design since the late-reply fix). The 600 s file timeout then failed the whole shard. The helper now polls for the queued DM (10 s bound) and, if it never appears, drops the mocked owner so the runner returns and the test fails on an assertion instead of hanging.
1413 lines
60 KiB
Python
1413 lines
60 KiB
Python
"""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 <json>`` 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:<hostname>/<profile>``: 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):
|
|
"""``<dm file>.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
|