hermes-agent/tests/gateway/test_raft_adapter.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
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.
2026-07-29 13:39:40 -07:00

220 lines
7 KiB
Python

"""Tests for the Raft channel adapter."""
import asyncio
import json
import os
from unittest.mock import AsyncMock, patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import Platform, PlatformConfig
from plugins.platforms.raft.adapter import (
ACTIVITY_DRAIN_SCHEMA,
ACTIVITY_EVENT_SCHEMA,
ActivityQueue,
BRIDGE_TOKEN_HEADER,
DEFAULT_PATH,
RaftAdapter,
_ACTIVE_ADAPTERS,
_ACTIVE_ADAPTERS_LOCK,
_RAFT_CONTEXT_LOCK,
_RAFT_PROMPT_TURN_IDS,
_RAFT_SESSION_IDS,
_RAFT_TURN_IDS,
_has_content_field,
_env_enablement,
_is_connected,
_on_session_start,
_on_pre_llm_call,
_on_pre_tool_call,
_on_post_llm_call,
_on_post_tool_call,
_on_session_end,
_on_session_finalize,
check_raft_requirements,
interactive_setup,
register,
)
from gateway.session import build_session_key
RAFT_CHANNEL_SCHEMA = "raft-channel-wake.v1"
FUTURE_RAFT_CHANNEL_SCHEMA = "raft-channel-wake.v2"
def _make_config(**extra):
data = {
"bridge_token": "bridge-secret",
"runtime_session": "default",
"port": 0,
}
data.update(extra)
return PlatformConfig(enabled=True, extra=data)
def _make_adapter(**extra):
return RaftAdapter(_make_config(**extra))
def _create_app(adapter: RaftAdapter) -> web.Application:
# Mirror connect(): client_max_size enforces the cap on chunked bodies.
app = web.Application(client_max_size=adapter._max_body_bytes)
app.router.add_get("/health", adapter._handle_health)
app.router.add_post(adapter._path, adapter._handle_wake)
app.router.add_post("/activity", adapter._handle_activity)
app.router.add_get("/activity/drain", adapter._handle_activity_drain)
return app
def _activity_event(event_id: str, **overrides):
event = {
"schema": ACTIVITY_EVENT_SCHEMA,
"eventId": event_id,
"sessionId": "session-1",
"hookEventName": "PreToolUse",
"status": "ok",
"occurredAt": "2026-06-16T06:00:00Z",
"toolName": "execute_code",
}
event.update(overrides)
return event
class TestRaftWakePayload:
def test_detects_content_fields(self):
assert _has_content_field({"text": "hello"}) is True
assert _has_content_field({"nested": {"messages": []}}) is True
assert _has_content_field({"eventId": "evt-1", "messageId": "msg-1"}) is False
class TestRaftWakeHttp:
@pytest.mark.asyncio
async def test_send_is_noop_success(self):
adapter = _make_adapter()
result = await adapter.send("default", "hello")
assert result.success is True
assert result.message_id is None
@pytest.mark.asyncio
async def test_rejects_content_bearing_payload(self):
adapter = _make_adapter()
adapter.set_message_handler(AsyncMock())
adapter.handle_message = AsyncMock()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
json={"eventId": "wake-1", "text": "do work"},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 400
body = await resp.json()
assert body == {"ok": False, "error": "content_not_allowed"}
adapter.handle_message.assert_not_called()
class TestRaftActivityHttp:
@pytest.mark.asyncio
async def test_activity_endpoint_auth_validation_and_drain(self):
adapter = _make_adapter()
adapter._activity_queue = ActivityQueue(cap=2)
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
unauthorized = await client.post("/activity", json=_activity_event("evt-1"))
assert unauthorized.status == 401
unknown = await client.post(
"/activity",
json={**_activity_event("evt-1"), "transcript_path": "/tmp/session.jsonl"},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert unknown.status == 400
for event_id in ["evt-1", "evt-2", "evt-3"]:
resp = await client.post(
"/activity",
json=_activity_event(event_id),
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 202
drain = await client.get(
"/activity/drain?max=10",
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert drain.status == 200
body = await drain.json()
assert body["schema"] == ACTIVITY_DRAIN_SCHEMA
assert body["dropped"] == 1
assert [event["eventId"] for event in body["events"]] == ["evt-2", "evt-3"]
class TestBodySize:
"""The wake/activity endpoints enforced max_body_bytes only via the
Content-Length header; a Transfer-Encoding: chunked request
(content_length=None) bypassed the cap entirely and read the full body,
bounded only by aiohttp's implicit 1 MiB default. Mirrors
gateway/platforms/webhook.py's TestBodySize."""
@pytest.mark.asyncio
async def test_wake_chunked_oversized_payload_rejected(self):
adapter = _make_adapter(max_body_bytes=100)
adapter.set_message_handler(AsyncMock())
adapter.handle_message = AsyncMock()
async def _chunked_body():
payload = json.dumps({"eventId": "x" * 500}).encode("utf-8")
for i in range(0, len(payload), 64):
yield payload[i : i + 64]
await asyncio.sleep(0)
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
data=_chunked_body(),
headers={
BRIDGE_TOKEN_HEADER: "bridge-secret",
"Content-Type": "application/json",
},
)
assert resp.status == 413
body = await resp.json()
assert body == {"ok": False, "error": "payload_too_large"}
adapter.handle_message.assert_not_awaited()
class TestRaftConfig:
def test_env_enablement_auto_enables_with_raft_profile(self, monkeypatch):
monkeypatch.setenv("RAFT_PROFILE", "my-agent")
extra = _env_enablement()
assert extra is not None
assert extra["enabled"] is True
def test_interactive_setup_keeps_existing_profile_when_not_reconfigured(
self, monkeypatch, tmp_path, capsys
):
env_path = tmp_path / ".env"
env_path.write_text("RAFT_PROFILE=existing\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("RAFT_PROFILE", "existing")
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
interactive_setup()
assert env_path.read_text(encoding="utf-8") == "RAFT_PROFILE=existing\n"
assert os.environ["RAFT_PROFILE"] == "existing"
assert "Keeping RAFT_PROFILE=existing" in capsys.readouterr().out