Files
hermes-agent/tests/gateway/test_base_warning_owner_scope.py
kshitijk4poor 70addd3522 fix(gateway): keep default-off byte parity; scope only the policy reads
Four places changed behaviour for users who never touched the setting:

- `_interim_send` was stamped on every `warn` status and media-failure notice, and the
  Slack/relay egress doors learned to skip stream sealing for it. Main's status sends carry
  no interim mark at all, so the gap is class-wide (every status kind), and fixing it for
  warnings alone is an undeclared streaming-contract change. Reverted here; the whole-class
  fix belongs in its own PR against gateway/AGENTS.md rule 3.
- The entire post-handler delivery (unwrap, TTS, final text, attachments, delivery-ledger
  writes) ran inside `_media_delivery_scope`. Under multiplex that binds the routed home, so
  delivery obligations landed in the routed profile's state.db while boot-time
  `_claim_pending_obligations` still reads the launch home. Only the policy reads
  (`diagnostic_wake_muted`, `warning_text`) bind the routed scope now; delivery stays where
  main ran it.
- The turn-crash notice is rebuilt the same way: scope around the policy read, send outside.
- The "delivery failed after multiple attempts" notice is unconditional again: the requested
  result itself was lost and this line is its only signal, so it is not a diagnostic.

Tests that asserted the reverted behaviours are removed; the reviewer-round test file is
renamed for what it covers.
2026-09-18 01:43:35 +05:30

199 lines
8.8 KiB
Python

"""Composed delivery policy: real profile files, runner scopes and adapter transport."""
import asyncio
import sys
import threading
import types
from pathlib import Path
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, ProcessingOutcome, SendResult
from gateway.platforms.event import MessageEvent
from gateway.run import GatewayRunner
from hermes_constants import get_hermes_home
class RecordingAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, typing_indicator=False), Platform.TELEGRAM)
self.wire = []
self.attempts = []
self.completed = []
self.fail_text_once = False
self.fail_media = False
async def connect(self, *, is_reconnect=False):
return True
async def disconnect(self):
pass
async def get_chat_info(self, chat_id):
return {"id": chat_id}
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.attempts.append((content, get_hermes_home()))
if self.fail_text_once:
self.fail_text_once = False
return SendResult(success=False, error="invalid markup")
self.wire.append((content, get_hermes_home(), metadata))
return SendResult(success=True, message_id=f"sent-{len(self.wire)}")
async def send_document(self, chat_id, file_path, **kwargs):
self.attempts.append((file_path, get_hermes_home()))
if self.fail_media:
return SendResult(success=False, error="transport rejected attachment")
return await super().send_document(chat_id, file_path, **kwargs)
async def on_processing_complete(self, event, outcome):
self.completed.append((event.text, outcome))
@pytest.fixture
def profiles(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
root = tmp_path / ".hermes"
homes = {name: root / "profiles" / name for name in ("a", "b")}
root.mkdir()
# Opposite to the routed opt-in profile; ambient reads must not decide delivery.
(root / "config.yaml").write_text("display: {suppress_warning_notifications: false}\n")
monkeypatch.setenv("HERMES_HOME", str(root))
for home in homes.values():
home.mkdir(parents=True)
return root, homes
@pytest.mark.asyncio
@pytest.mark.parametrize("diagnostic_first", [False, True])
@pytest.mark.parametrize("setting", [None, False, True])
async def test_recursive_three_turn_chain_then_human(profiles, monkeypatch, diagnostic_first, setting):
"""Real runner recursion and final-event handoff, not a copied mute snapshot."""
import gateway.run as gateway_run
from gateway.session import SessionSource
root, _ = profiles
monkeypatch.setattr(gateway_run, "_hermes_home", root)
(root / "config.yaml").write_text(
"display: {tool_progress: off, streaming: false" +
("}" if setting is None else f", suppress_warning_notifications: {str(setting).lower()}}}")
)
from gateway.config import HomeChannel
config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(
enabled=True, home_channel=HomeChannel(platform=Platform.TELEGRAM, chat_id="42", name="test"))})
runner = GatewayRunner(config)
adapter = RecordingAdapter()
adapter.gateway_runner = runner
runner.adapters = {Platform.TELEGRAM: adapter}
source = SessionSource(platform=Platform.TELEGRAM, chat_id="42", user_id="42")
key = runner._session_key_for_source(source)
events = [MessageEvent(
text=f"turn-{i}", source=source, message_id=f"inbound-{i}",
internal=(diagnostic_first if i % 2 == 0 else not diagnostic_first),
metadata={"notification_category": "diagnostic"},
) for i in range(3)]
# An untrusted category in metadata is deliberately present on human inputs.
events.append(MessageEvent(text="next human", source=source, message_id="inbound-next",
metadata={"notification_category": "diagnostic"}))
original_authority = [(e.internal, e.allow_gateway_control) for e in events]
calls = []
loop = asyncio.get_running_loop()
class ScriptedAgent:
def __init__(self, **kwargs):
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
calls.append(message)
index = len(calls) - 1
if index < 2:
ready = threading.Event()
def enqueue():
adapter._pending_messages[key] = events[index + 1]
ready.set()
loop.call_soon_threadsafe(enqueue)
assert ready.wait(5), "event loop did not admit queued turn"
return {"final_response": f"result-{index}", "messages": [], "api_calls": 1}
fake = types.ModuleType("run_agent")
fake.AIAgent = ScriptedAgent
monkeypatch.setitem(sys.modules, "run_agent", fake)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "test-key"})
# No platform credentials: authorize the fixture identity at the auth backend,
# retaining admission, generation/lease lifecycle and queued-result handoff.
authorized = []
def authorize(source, **kwargs):
authorized.append(source.user_id)
return source.user_id == "42"
runner._is_user_authorized_for_source = authorize
adapter.set_message_handler(runner._handle_message)
try:
await adapter._process_message_background(events[0], key)
assert len(calls) == 3, (calls, adapter.wire)
expected = [f"result-{i}" for i in range(3)
if not (setting is True and original_authority[i][0])]
assert [row[0] for row in adapter.wire] == expected
await adapter._process_message_background(events[3], key)
assert len(calls) == 4
assert [row[0] for row in adapter.wire] == expected + ["result-3"]
assert [(e.internal, e.allow_gateway_control) for e in events] == original_authority
assert authorized[-1] == "42" # next human still crosses authorization
denied = MessageEvent(
text="⚠️ diagnostic-looking human input", internal=False,
source=SessionSource(platform=Platform.TELEGRAM, chat_id="group", chat_type="group",
user_id="denied"),
metadata={"notification_category": "diagnostic", "_notification_reply_muted": True})
before = list(adapter.wire)
await adapter._process_message_background(denied, runner._session_key_for_source(denied.source))
assert authorized[-1] == "denied"
assert len(calls) == 4 and adapter.wire == before
assert denied.internal is False and denied.allow_gateway_control is True
assert get_hermes_home() == root
finally:
await adapter.cancel_background_tasks()
@pytest.mark.asyncio
@pytest.mark.parametrize("setting", [None, False, True])
@pytest.mark.parametrize("diagnostic", [False, True])
async def test_turn_error_preserves_diagnostic_bridge_and_failed_outcome(
profiles, monkeypatch, setting, diagnostic, caplog):
import logging
from types import SimpleNamespace
from agent.monitoring import gateway_health
root, _ = profiles
(root / "config.yaml").write_text(
"{}" if setting is None else
f"display: {{suppress_warning_notifications: {str(setting).lower()}}}")
emitted = []
monkeypatch.setattr(gateway_health.emitter, "get_emitter",
lambda: SimpleNamespace(emit=emitted.append))
logger = logging.getLogger("gateway.platforms.base")
bridge = gateway_health.GatewayDiagnosticLogHandler(profile="fixture", version="fixture")
logger.addHandler(bridge)
adapter = RecordingAdapter()
async def handler(event):
raise RuntimeError("same source failure")
adapter.set_message_handler(handler)
source = adapter.build_source(chat_id="42", user_id="42")
event = MessageEvent(text="work", source=source, internal=diagnostic,
metadata={"notification_category": "diagnostic"})
try:
key = "agent:default:telegram:dm:42"
assert adapter._start_session_processing(event, key)
await adapter._session_tasks[key]
assert adapter.completed[-1][1] is ProcessingOutcome.FAILURE
assert "same source failure" in caplog.text
errors = [e for e in emitted if e.name == "gateway.log.error"]
assert len(errors) == 1
assert errors[0].severity == "error" and errors[0].profile == "fixture"
if diagnostic and setting is True:
assert adapter.wire == []
else:
assert len(adapter.wire) == 1
assert ("same source failure" in adapter.wire[0][0]) is (setting is not True)
assert not adapter._active_sessions
finally:
logger.removeHandler(bridge)
await adapter.cancel_background_tasks()