Files
hermes-agent/tests/gateway/test_native_warning_coverage.py
Victor Kyriazakos cd3de040ab feat(notifications): opt-in suppression of user-channel warning notifications
Squash of the 54 commits on victor-kyriazakos:feat/user-channel-warning-suppression
(PR #112302, head f45c640e55) so the contributor's authorship survives a rebase-merge;
the commits interleave with a cron delivery-ledger rework that the salvage removes in
follow-up commits, so per-commit cherry-picks were not practical.

Adds display.suppress_warning_notifications (global + per-platform, default false):
one resolver (gateway/warning_notifications.py), BasePlatformAdapter.emit_warning /
emit_media_warning / warning_text, a notification_category classification carried
through wakes, queues and persistence, and render/present boundaries for CLI/TUI.
2026-09-18 01:43:35 +05:30

317 lines
14 KiB
Python

"""Native diagnostic producers keep captions, controls and truthful receipts.
No live platform connections: actual adapters run against recording transports.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import SendResult
from plugins.platforms.buzz.adapter import BuzzAdapter
from plugins.platforms.discord.adapter import DiscordAdapter
from plugins.platforms.google_chat.adapter import GoogleChatAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
from plugins.platforms.wecom.adapter import WeComAdapter
@pytest.fixture(params=[None, False, True, "override"])
def policy(request, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
setting = request.param
if setting is not None:
display = {"suppress_warning_notifications": setting is True}
if setting == "override":
display["platforms"] = {
name: {"suppress_warning_notifications": True}
for name in ("matrix", "google_chat", "discord", "wecom", "buzz", "mattermost", "feishu", "telegram")
}
(tmp_path / "config.yaml").write_text(json.dumps({"display": display}))
return setting is True or setting == "override"
@pytest.mark.asyncio
@pytest.mark.parametrize("caption", [None, "Error: this is the requested caption"])
async def test_matrix_download_diagnostic_not_caption_is_suppressed(policy, caption, monkeypatch, caplog):
adapter = MatrixAdapter(PlatformConfig())
frames = []
async def record(room, event_type, content):
frames.append(content)
return "$sent"
adapter._client = SimpleNamespace(send_message_event=record)
adapter._download_external_media_with_cap = AsyncMock(side_effect=RuntimeError("download fixture"))
monkeypatch.setattr("tools.url_safety.is_safe_url", lambda url: True)
result = await adapter.send_image("!room", "https://example.invalid/image?token=private", caption, "$reply", {"thread_id": "$thread"})
if policy:
assert [f["body"] for f in frames] == ([caption] if caption else [])
assert not result.success and result.message_id is None
if frames:
assert frames[0]["m.relates_to"]["event_id"] == "$thread"
else:
notice = "I couldn't download and upload the image to Matrix. The source URL was not shown because it may contain private tokens."
assert [f["body"] for f in frames] == [f"{caption}\n{notice}" if caption else notice]
assert result.success and result.message_id == "$sent"
assert "failed to download image" in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize("caption", [None, "Warning: requested document caption"])
async def test_google_chat_oauth_fallback_only_hides_diagnostic(policy, caption, tmp_path):
adapter = GoogleChatAdapter(PlatformConfig())
frames = []
async def record(chat, body):
frames.append(body)
return SendResult(success=True, message_id="spaces/test/messages/sent")
adapter._create_message = record
adapter._acquire_user_chat_api = AsyncMock(return_value=(None, None))
path = tmp_path / "report.pdf"
path.write_bytes(b"fixture")
result = await adapter.send_document("spaces/test", str(path), caption=caption, metadata={"thread_id": "spaces/test/threads/thread"})
assert not result.success and "OAuth" in result.error
if policy:
assert [f["text"] for f in frames] == ([caption] if caption else [])
else:
assert len(frames) == 1 and "/setup-files" in frames[0]["text"]
assert str(path) in frames[0]["text"]
if caption:
assert frames[0]["text"].startswith(caption + "\n")
if frames:
assert frames[0]["thread"]["name"] == "spaces/test/threads/thread"
# An explicit control request must still use the same transport.
await adapter.send_clarify("spaces/test", "Continue?", ["yes", "no"], "clarify", "session")
assert "cardsV2" in frames[-1]
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["send_document", "send_voice", "send_multiple_images"])
@pytest.mark.parametrize("forum", [False, True])
async def test_discord_rejected_media_preserves_caption_without_fake_receipt(policy, method, forum, tmp_path, caplog):
adapter = DiscordAdapter(PlatformConfig())
frames = []
async def record(**payload):
frames.append(payload)
return SimpleNamespace(id=123, attachments=[])
async def create_thread(**payload):
frames.append(payload)
return SimpleNamespace(thread=SimpleNamespace(id=101), message=SimpleNamespace(id=102))
channel = SimpleNamespace(send=record, id=99, create_thread=create_thread)
adapter._client = SimpleNamespace()
adapter._resolve_channel = AsyncMock(return_value=channel)
adapter._is_forum_parent = lambda channel: forum
adapter._discord_upload_limit_bytes = lambda channel: 1
path = tmp_path / "large.png"
path.write_bytes(b"too large")
caption = "Warning: requested caption"
if method == "send_multiple_images":
result = await adapter.send_multiple_images("99", [(path.as_uri(), caption)])
else:
result = await getattr(adapter, method)("99", str(path), caption=caption)
assert not result.success and result.message_id is None
if policy:
assert [f["content"] for f in frames] == [caption]
elif forum:
assert frames == [] # legacy forum rejection sends no notice
else:
assert len(frames) == 1 and "⚠️" in frames[0]["content"]
assert caption not in frames[0]["content"] # preserve legacy default
assert "large" in caplog.text
@pytest.mark.asyncio
async def test_wecom_rejected_media_keeps_caption_and_failed_result(policy):
adapter = WeComAdapter(PlatformConfig())
frames = []
async def record(command, body, **kwargs):
frames.append(body)
return {"errcode": 0, "headers": {"req_id": "sent"}}
adapter._send_request = record
adapter._prepare_outbound_media = AsyncMock(return_value={"rejected": True, "reject_reason": "fixture rejection"})
result = await adapter.send_document("chat", "fixture.pdf", caption="Error: requested caption")
assert not result.success and result.error == "fixture rejection"
assert [f["markdown"]["content"] for f in frames] == (["Error: requested caption"] if policy else ["⚠️ fixture rejection"])
@pytest.mark.asyncio
async def test_buzz_missing_upload_fallback_and_real_upload_receipts(policy, tmp_path):
adapter = BuzzAdapter(PlatformConfig(extra={"cli_path": "fixture-buzz"}))
adapter._mention_pubkeys_for = AsyncMock(return_value=[])
frames = []
async def record(args, content, *extra):
frames.append((args, content))
return 0, json.dumps({"event_id": "a" * 64, "accepted": True}), ""
adapter._run_message_send = record
result = await adapter.send_image_file("chat", str(tmp_path / "missing.png"), caption="requested caption")
assert frames[0][1] == ("requested caption" if policy else "requested caption\n⚠️ Couldn't deliver the image attachment.")
assert result.success is (not policy)
path = tmp_path / "image.png"
path.write_bytes(b"fixture")
result = await adapter.send_image_file("chat", str(path), caption="⚠️ user caption")
assert result.success and result.message_id == "a" * 64
assert "--file" in frames[-1][0] and frames[-1][1] == "⚠️ user caption"
@pytest.mark.asyncio
@pytest.mark.parametrize("lane", ["send", "edit"])
async def test_discord_truncation_warning_covers_send_and_final_edit(policy, lane):
adapter = DiscordAdapter(PlatformConfig())
frames = []
async def record(**payload):
frames.append(payload["content"])
return SimpleNamespace(id=123, to_reference=lambda **kwargs: None)
message = SimpleNamespace(id=123, edit=record, to_reference=lambda **kwargs: None)
channel = SimpleNamespace(send=record, get_partial_message=lambda ident: message, id=99)
adapter._client = SimpleNamespace()
adapter._resolve_channel = AsyncMock(return_value=channel)
adapter._is_forum_parent = lambda channel: False
adapter._record_response_async = AsyncMock()
adapter.MAX_MESSAGE_LENGTH = 80
adapter.MAX_SPLIT_MESSAGES = 3
text = "Warning: requested result. " * 40
if lane == "send":
result = await adapter.send("99", text)
else:
result = await adapter.edit_message("99", "123", text, finalize=True)
assert result.success
assert frames[0].startswith("Warning: requested result.")
assert any("Response truncated" in frame for frame in frames) is (not policy)
assert len(frames) == (2 if policy else 3)
@pytest.mark.asyncio
async def test_wecom_downgrade_preserves_native_upload_and_caption(policy):
adapter = WeComAdapter(PlatformConfig())
frames = []
async def record(cmd, body, **kwargs):
frames.append((cmd, body))
return {"errcode": 0, "body": {"upload_id": "upload", "media_id": "media"}, "headers": {"req_id": "sent"}}
adapter._send_request = record
adapter._load_outbound_media = AsyncMock(return_value=(b"fixture", "audio/ogg", "voice.ogg"))
result = await adapter.send_voice("chat", "voice.ogg", caption="Error: user caption")
assert result.success
assert any(body.get("file", {}).get("media_id") == "media" for _, body in frames)
text = [body["markdown"]["content"] for _, body in frames if "markdown" in body]
assert text[0] == "Error: user caption"
assert len(text) == (1 if policy else 2)
@pytest.mark.asyncio
async def test_google_chat_revoked_oauth_fallback_uses_same_policy(policy, tmp_path, monkeypatch, caplog):
from plugins.platforms.google_chat import adapter as google
class Revoked(Exception):
resp = SimpleNamespace(status=403)
def fail(**kwargs):
raise Revoked("token revoked")
adapter = GoogleChatAdapter(PlatformConfig())
adapter._acquire_user_chat_api = AsyncMock(return_value=(SimpleNamespace(media=lambda: SimpleNamespace(upload=fail)), "owner"))
adapter._user_creds_by_email["owner"] = object()
adapter._user_chat_api_by_email["owner"] = object()
monkeypatch.setattr(google, "HttpError", Revoked)
monkeypatch.setattr(google, "MediaFileUpload", lambda *args, **kwargs: object())
frames = []
async def record(chat_id, body):
frames.append(body)
return SendResult(success=True, message_id="sent")
adapter._create_message = record
path = tmp_path / "file.pdf"
path.write_bytes(b"fixture")
result = await adapter.send_document("chat", str(path), caption="requested caption")
assert not result.success
assert "owner" not in adapter._user_creds_by_email
assert "owner" not in adapter._user_chat_api_by_email
assert "media.upload auth failure" in caplog.text
assert frames[0]["text"] == "requested caption" if policy else "/setup-files" in frames[0]["text"]
@pytest.mark.asyncio
async def test_mattermost_thread_fallback_keeps_requested_result(policy, caplog):
from plugins.platforms.mattermost.adapter import MattermostAdapter
adapter = MattermostAdapter(PlatformConfig(extra={"reply_mode": "thread"}))
frames = []
async def api(method, path, payload=None):
frames.append(dict(payload))
if "root_id" in payload:
adapter._last_post_status = 404
adapter._last_post_error = "root_id not found"
return {}
return {"id": "sent"}
adapter._api = api
adapter._resolve_root_id = AsyncMock(return_value="root")
result = await adapter.send("chat", "Error: requested result", metadata={"thread_id": "root", "notify": True})
assert result.success
assert frames[0]["root_id"] == "root" and "root_id" not in frames[1]
expected = "Error: requested result"
if not policy:
expected = "⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n" + expected
assert frames[1]["message"] == expected
assert "falling back to flat channel" in caplog.text
@pytest.mark.asyncio
async def test_feishu_downgrade_keeps_attachment_and_caption(policy, tmp_path):
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
frames = []
path = tmp_path / "animation.gif"
path.write_bytes(b"fixture")
adapter._download_remote_document = AsyncMock(return_value=(str(path), path.name))
def upload(request):
return SimpleNamespace(success=lambda: True, code=0, data=SimpleNamespace(file_key="file-key"))
async def send_raw(**kwargs):
frames.append(kwargs)
return SimpleNamespace(success=lambda: True, code=0, data=SimpleNamespace(message_id="sent"))
adapter._client = SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(file=SimpleNamespace(create=upload))))
adapter._send_raw_message = send_raw
result = await adapter.send_animation("chat", "https://example.invalid/animation.gif", caption="Warning: requested caption")
assert result.success
assert "file-key" in frames[0]["payload"]
assert "Warning: requested caption" in frames[0]["payload"]
assert ("GIF downgraded to file" in frames[0]["payload"]) is (not policy)
@pytest.mark.asyncio
async def test_telegram_cache_warning_does_not_hide_inbound_context(policy):
from plugins.platforms.telegram.adapter import TelegramAdapter
adapter = TelegramAdapter(PlatformConfig())
frames = []
async def reply_text(text):
frames.append(text)
from gateway.platforms.event import MessageEvent
event = MessageEvent(text="Requested content", source=adapter.build_source(chat_id="42", user_id="42"))
await adapter._surface_media_cache_failure(SimpleNamespace(reply_text=reply_text), event, "photo", RuntimeError("fixture"))
assert bool(frames) is (not policy)
assert event.text.startswith("Requested content")
assert "could not be downloaded" in event.text
assert ("asked to retry" in event.text) is (not policy)