mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
"""Streaming intentional-silence suppression.
|
|
|
|
When the agent chooses not to reply it emits a bare control marker
|
|
(``NO_REPLY`` / ``[SILENT]`` / …). The gateway's whole-response filter
|
|
(``gateway/response_filters.is_intentional_silence_agent_result``) suppresses
|
|
this on the non-streaming delivery path, but the *streaming* path
|
|
(``GatewayStreamConsumer``) previously had no silence awareness: it edited the
|
|
raw marker onto the screen delta-by-delta and finalized it *before* the
|
|
whole-response filter could run. On any streaming-capable adapter (Slack,
|
|
Telegram, Discord, …) users saw a literal ``NO_REPLY`` bubble.
|
|
|
|
These tests pin the two halves of the fix:
|
|
|
|
* ``is_partial_silence_marker`` — the mid-stream hold-back predicate.
|
|
* ``GatewayStreamConsumer`` — an exact-marker final buffer is suppressed and
|
|
any already-shown preview is retracted, while substantive prose that merely
|
|
mentions a marker is delivered normally.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.response_filters import (
|
|
is_intentional_silence_response,
|
|
is_partial_silence_marker,
|
|
)
|
|
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# is_partial_silence_marker — mid-stream hold-back predicate
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Buffers that could still resolve to a marker → held back while streaming.
|
|
PARTIAL_POSITIVE = [
|
|
"N",
|
|
"NO",
|
|
"NO_",
|
|
"NO_REP",
|
|
"NO_REPLY", # exact marker, not yet terminated by stream-end
|
|
"NO REPLY",
|
|
"no reply", # canonicalized (case/space-insensitive)
|
|
" no_reply ", # surrounding whitespace stripped
|
|
"[",
|
|
"[SIL",
|
|
"[SILENT]",
|
|
"SILENT",
|
|
"sil",
|
|
]
|
|
|
|
# Buffers that have already diverged from every marker → stream normally.
|
|
PARTIAL_NEGATIVE = [
|
|
"",
|
|
" ",
|
|
"No reply needed — here is the plan", # diverged past the marker
|
|
"NO_REPLYING", # superset, not a prefix
|
|
"Nope",
|
|
"Hello there",
|
|
"The NO_REPLY token means silence", # marker mentioned mid-prose
|
|
"x" * 65, # over the 64-char cap
|
|
"silence is golden", # 'SILENCE...' is not a marker prefix
|
|
]
|
|
|
|
|
|
def test_partial_predicate_agrees_with_exact_on_full_markers():
|
|
"""Every exact silence marker is also a (trivial) partial of itself."""
|
|
from gateway.response_filters import LIVE_GATEWAY_SILENT_MARKERS
|
|
|
|
for marker in LIVE_GATEWAY_SILENT_MARKERS:
|
|
assert is_partial_silence_marker(marker) is True
|
|
assert is_intentional_silence_response(marker) is True
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# GatewayStreamConsumer — end-to-end suppression through run()
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _make_adapter(*, supports_delete: bool = True) -> MagicMock:
|
|
"""Minimal MagicMock adapter wired for send/edit/delete."""
|
|
adapter = MagicMock()
|
|
adapter.REQUIRES_EDIT_FINALIZE = False
|
|
adapter.MAX_MESSAGE_LENGTH = 4096
|
|
adapter.send = AsyncMock(return_value=SimpleNamespace(
|
|
success=True, message_id="preview_1",
|
|
))
|
|
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
|
success=True, message_id="preview_1",
|
|
))
|
|
if supports_delete:
|
|
adapter.delete_message = AsyncMock(return_value=True)
|
|
else:
|
|
del adapter.delete_message # type: ignore[attr-defined]
|
|
return adapter
|
|
|
|
|
|
def _sent_and_edited(adapter):
|
|
texts = []
|
|
for call in adapter.send.call_args_list:
|
|
texts.append(call.kwargs.get("content", ""))
|
|
if getattr(adapter, "edit_message", None) is not None:
|
|
for call in adapter.edit_message.call_args_list:
|
|
texts.append(call.kwargs.get("content", ""))
|
|
return texts
|
|
|
|
|
|
class TestStreamedSilenceSuppression:
|
|
@pytest.mark.asyncio
|
|
async def test_no_reply_only_stream_is_fully_suppressed(self):
|
|
"""A stream whose entire content is NO_REPLY sends nothing visible."""
|
|
adapter = _make_adapter()
|
|
consumer = GatewayStreamConsumer(
|
|
adapter, "chat_1",
|
|
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
|
|
)
|
|
consumer.on_delta("NO_REPLY")
|
|
consumer.finish()
|
|
await consumer.run()
|
|
|
|
# No marker text ever reached the platform.
|
|
for text in _sent_and_edited(adapter):
|
|
assert "NO_REPLY" not in text, f"marker leaked: {text!r}"
|
|
|
|
# Delivery flags stay False so the gateway does not treat the marker
|
|
# as a delivered reply (its whole-response filter then drops it too).
|
|
assert consumer.final_response_sent is False
|
|
assert consumer.final_content_delivered is False
|
|
assert consumer.already_sent is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_marker_preview_is_retracted(self):
|
|
"""A marker flushed mid-stream as a preview is deleted on completion."""
|
|
adapter = _make_adapter()
|
|
consumer = GatewayStreamConsumer(
|
|
adapter, "chat_1",
|
|
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1),
|
|
)
|
|
# Force a mid-stream preview: pretend "NO_REPLY" was already put on
|
|
# screen (the pre-fix behaviour) before got_done runs.
|
|
consumer._message_id = "preview_1"
|
|
consumer._preview_message_ids = {"preview_1"}
|
|
consumer._already_sent = True
|
|
|
|
consumer.on_delta("NO_REPLY")
|
|
consumer.finish()
|
|
await consumer.run()
|
|
|
|
# The stale preview was best-effort deleted.
|
|
adapter.delete_message.assert_awaited_once_with("chat_1", "preview_1")
|
|
assert consumer.final_content_delivered is False
|
|
assert consumer.already_sent is False
|
|
|
|
|