Files
hermes-agent/tests/gateway/test_api_notification_outcomes.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

201 lines
10 KiB
Python

"""Diagnostic presentation does not rewrite execution evidence or next-turn callbacks."""
import copy
from unittest.mock import MagicMock, patch
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter, _ProviderAuthResolutionError
@pytest.mark.asyncio
@pytest.mark.parametrize("setting", [None, False, True])
@pytest.mark.parametrize("category", ["diagnostic", "result"])
async def test_notification_projection_preserves_source_outcome(tmp_path, monkeypatch, setting, category):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("{}" if setting is None else
f"display: {{suppress_warning_notifications: {str(setting).lower()}}}\n")
adapter = APIServerAdapter(PlatformConfig(enabled=True))
source = {"final_response": "execution evidence", "completed": False, "failed": True,
"error": "failure details", "messages": [{"role": "assistant", "content": "execution evidence"}]}
expected = copy.deepcopy(source)
agent = MagicMock()
agent.session_id = "session"
agent._last_compaction_in_place = False
agent.session_prompt_tokens = 3
agent.session_completion_tokens = 4
agent.session_total_tokens = 7
delivered = []
callback = lambda text: delivered.append(text)
agent.stream_delta_callback = callback
def run(**kwargs):
if agent.stream_delta_callback:
agent.stream_delta_callback("execution evidence")
return source
agent.run_conversation.side_effect = run
finished = []
finish = adapter._finish_turn_result
def record_finish(agent, result, *args, **kwargs):
finished.append(copy.deepcopy(result))
return finish(agent, result, *args, **kwargs)
with patch.object(adapter, "_create_agent", return_value=agent), patch.object(adapter, "_finish_turn_result", side_effect=record_finish):
result, usage = await adapter._run_agent("internal event", [], session_id="session", notification_category=category)
muted = setting is True and category == "diagnostic"
assert finished == [expected]
assert source["final_response"] == expected["final_response"]
assert result["final_response"] == "execution evidence"
assert bool(result.get("_notification_presentation_suppressed")) is muted
assert result["failed"] is True and result["error"] == "failure details"
assert result["messages"] == expected["messages"]
assert usage == {"input_tokens": 3, "output_tokens": 4, "total_tokens": 7}
assert delivered == ([] if muted else ["execution evidence"])
assert agent.stream_delta_callback is callback
assert adapter._inflight_agent_runs == 0
assert adapter._shutdown_interruptible_agents == {}
@pytest.mark.asyncio
@pytest.mark.parametrize("setting", [None, False, True])
async def test_pre_agent_auth_diagnostic_obeys_policy_without_losing_logs(tmp_path, monkeypatch, caplog, setting):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("{}" if setting is None else
f"display: {{suppress_warning_notifications: {str(setting).lower()}}}\n")
adapter = APIServerAdapter(PlatformConfig(enabled=True))
with patch.object(adapter, "_create_agent", side_effect=_ProviderAuthResolutionError("fixture unavailable")):
result, usage = await adapter._run_agent("internal event", [], session_id="session", notification_category="diagnostic")
assert "fixture unavailable" in result["final_response"]
assert bool(result.get("_notification_presentation_suppressed")) is (setting is True)
assert "fixture unavailable" in caplog.text
assert result["api_calls"] == 0 and usage["total_tokens"] == 0
assert adapter._inflight_agent_runs == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("setting", [None, False, True])
async def test_http_diagnostic_projection_keeps_source_and_terminal_flags(tmp_path, monkeypatch, stream, setting):
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import json
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("{}" if setting is None else
f"display: {{suppress_warning_notifications: {str(setting).lower()}}}\n")
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"key": "test-local-key"}))
app = web.Application()
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
source = {"final_response": "private diagnostic evidence", "completed": False, "partial": True,
"failed": False, "error": "private diagnostic detail", "messages": []}
agent = MagicMock()
agent.session_id = "session"
agent._last_compaction_in_place = False
agent.session_prompt_tokens = 3
agent.session_completion_tokens = 4
agent.session_total_tokens = 7
def create(**kwargs):
agent.stream_delta_callback = kwargs.get("stream_delta_callback")
return agent
def run(**kwargs):
if agent.stream_delta_callback:
agent.stream_delta_callback("private diagnostic evidence")
return source
agent.run_conversation.side_effect = run
with patch.object(adapter, "_create_agent", side_effect=create):
async with TestClient(TestServer(app)) as client:
response = await client.post("/v1/chat/completions", headers={
"Authorization": "Bearer test-local-key", "X-Hermes-Session-Id": "session"}, json={
"messages": [{"role": "user", "content": "background diagnostic"}],
"stream": stream, "hermes_notification_category": "diagnostic"})
wire = await response.text()
assert response.status == 200, wire
assert source["final_response"] == "private diagnostic evidence"
assert source["error"] == "private diagnostic detail"
if setting is True:
assert "private diagnostic" not in wire
else:
assert "private diagnostic evidence" in wire
if stream:
frames = [json.loads(line[6:]) for line in wire.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"]
terminal = frames[-1]
assert terminal["choices"][0]["finish_reason"] == "error"
assert terminal["hermes"]["partial"] is True
assert terminal["usage"]["total_tokens"] == 7
else:
body = json.loads(wire)
assert body["choices"][0]["finish_reason"] == "error"
assert body["hermes"]["partial"] is True
assert body["usage"]["total_tokens"] == 7
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("setting", [None, False, True])
async def test_http_unhandled_diagnostic_error_is_quiet_but_failed(tmp_path, monkeypatch, caplog, stream, setting):
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import json
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("{}" if setting is None else
f"display: {{suppress_warning_notifications: {str(setting).lower()}}}\n")
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"key": "test-local-key"}))
app = web.Application()
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
with patch.object(adapter, "_create_agent", side_effect=RuntimeError("private constructor failure")):
async with TestClient(TestServer(app)) as client:
response = await client.post("/v1/chat/completions", headers={
"Authorization": "Bearer test-local-key", "X-Hermes-Session-Id": "session"}, json={
"messages": [{"role": "user", "content": "background diagnostic"}],
"stream": stream, "hermes_notification_category": "diagnostic"})
wire = await response.text()
assert response.status == (200 if stream else 500), wire
assert "private constructor failure" in caplog.text
assert ("private constructor failure" in wire) is (setting is not True)
if stream:
frames = [json.loads(line[6:]) for line in wire.splitlines()
if line.startswith("data: ") and line != "data: [DONE]"]
assert frames[-1]["choices"][0]["finish_reason"] == "error"
assert frames[-1]["hermes"]["failed"] is True
assert adapter._inflight_agent_runs == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("categories", [("diagnostic", "result"), ("result", "diagnostic")])
async def test_http_idempotency_does_not_replay_opposite_presentation(tmp_path, monkeypatch, categories):
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.platforms.api_server import _IdempotencyCache
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("gateway.platforms.api_server._idem_cache", _IdempotencyCache())
(tmp_path / "config.yaml").write_text("display: {suppress_warning_notifications: true}\n")
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"key": "test-local-key"}))
app = web.Application()
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
agent = MagicMock()
agent.session_id = "session"
agent._last_compaction_in_place = False
agent.session_prompt_tokens = agent.session_completion_tokens = agent.session_total_tokens = 0
agent.run_conversation.side_effect = lambda **kwargs: {"final_response": "required result", "messages": []}
with patch.object(adapter, "_create_agent", return_value=agent):
async with TestClient(TestServer(app)) as client:
for category in categories:
body = {"messages": [{"role": "user", "content": "event"}],
"hermes_notification_category": category}
for repeat in range(2):
response = await client.post("/v1/chat/completions", headers={
"Authorization": "Bearer test-local-key", "X-Hermes-Session-Id": "session",
"Idempotency-Key": "same-key"}, json=body)
wire = await response.json()
assert response.status == 200, wire
assert wire["choices"][0]["message"]["content"] == ("" if category == "diagnostic" else "required result")
assert agent.run_conversation.call_count == 2