hermes-agent/tests/gateway/test_raft_adapter.py
skyzh 9026a8c789 feat(gateway): add Raft bundled platform plugin with activity hooks
Adds a Raft platform adapter as a bundled plugin (plugins/platforms/raft/)
connecting Hermes to Raft as an external agent via a wake-channel bridge.
The adapter starts a loopback HTTP endpoint, spawns 'raft agent bridge' as a
child process, and injects content-free wake hints into the gateway session
pipeline. The agent reads/sends messages through the Raft CLI; the adapter
never touches message bodies or delivery cursors. Activity observer hooks
report tool/LLM/session lifecycle events via a bounded at-most-once queue.
Auto-enables when RAFT_PROFILE is set.

Cherry-picked from PR #47629. Authored by skyzh (@xxchan).
2026-06-19 07:52:37 -07:00

455 lines
16 KiB
Python

"""Tests for the Raft channel adapter."""
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,
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:
app = web.Application()
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_missing_bridge_token(self):
adapter = _make_adapter()
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"})
assert resp.status == 401
body = await resp.json()
assert body["ok"] is False
adapter.handle_message.assert_not_called()
@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()
@pytest.mark.asyncio
async def test_returns_not_ready_without_gateway_handler(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
json={"eventId": "wake-1"},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 503
body = await resp.json()
assert body["ok"] is False
assert body["runtimeSession"] == "default"
@pytest.mark.asyncio
@pytest.mark.parametrize("schema", [RAFT_CHANNEL_SCHEMA, FUTURE_RAFT_CHANNEL_SCHEMA])
async def test_accepts_content_free_wake_as_internal_event(self, schema):
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={
"schema": schema,
"attemptId": "attempt-1",
"eventId": "wake-1",
"messageId": "msg-1",
"agentId": "agent-1",
"profile": "dev",
"coreSessionId": "default",
"adapterInstance": "hermes",
"occurredAt": "2026-06-11T08:00:00Z",
},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 202
body = await resp.json()
assert body == {"ok": True, "runtimeSession": "default"}
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.internal is True
assert event.message_id == "wake-1"
assert event.raw_message["schema"] == schema
assert event.raw_message["eventId"] == "wake-1"
assert event.raw_message["attemptId"] == "attempt-1"
assert event.raw_message["messageId"] == "msg-1"
assert event.source.platform == Platform("raft")
assert event.source.chat_id == "default"
assert "raft manual get" in event.text
@pytest.mark.asyncio
async def test_busy_session_queues_without_interrupt(self):
handler = AsyncMock()
adapter = _make_adapter()
adapter.set_message_handler(handler)
source = adapter.build_source(
chat_id="default",
chat_name="Raft channel",
chat_type="dm",
user_id="raft-bridge",
user_name="Raft Bridge",
)
session_key = build_session_key(source)
adapter._active_sessions[session_key] = __import__("asyncio").Event()
accepted = await adapter._accept_wake({"eventId": "wake-busy"})
assert accepted is True
handler.assert_not_called()
assert session_key in adapter._pending_messages
pending = adapter._pending_messages[session_key]
assert pending.message_id == "wake-busy"
assert "raft manual get" in pending.text
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"]
def test_hook_mapping_reports_only_raft_context(self):
adapter = _make_adapter()
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.add(adapter)
try:
_on_pre_tool_call(
session_id="session-1",
turn_id="turn-1",
tool_name="execute_code",
args={"cmd": "echo nope"},
)
assert adapter._activity_queue.drain(10)["events"] == []
_on_pre_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
user_message="run a probe",
)
_on_pre_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
user_message="run a follow-up LLM call in the same turn",
)
_on_pre_tool_call(
session_id="session-1",
turn_id="turn-1",
tool_name="execute_code",
args={"cmd": "echo ok"},
)
_on_post_tool_call(
session_id="session-1",
turn_id="turn-1",
tool_name="execute_code",
args={"cmd": "echo ok"},
result="ok",
status="ok",
duration_ms=321,
)
_on_post_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
assistant_response="done",
)
_on_session_end(
platform="raft",
session_id="session-1",
turn_id="turn-1",
completed=True,
interrupted=False,
)
_on_session_finalize(
platform="raft",
session_id="session-1",
reason="shutdown",
)
drain = adapter._activity_queue.drain(10)
finally:
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.discard(adapter)
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
assert [event["hookEventName"] for event in drain["events"]] == [
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SessionEnd",
]
tool_start = drain["events"][1]
assert tool_start["toolName"] == "execute_code"
assert '"cmd": "echo ok"' in tool_start["toolInput"]
tool_result = drain["events"][2]
assert tool_result["durationMs"] == 321
def test_session_start_registers_raft_profile_env_passthrough(self):
import tools.env_passthrough as env_passthrough_mod
from tools.code_execution_tool import _scrub_child_env
from tools.environments.local import _make_run_env
from tools.env_passthrough import clear_env_passthrough, is_env_passthrough
previous_config_passthrough = env_passthrough_mod._config_passthrough
clear_env_passthrough()
env_passthrough_mod._config_passthrough = frozenset()
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
try:
assert "RAFT_PROFILE" not in _scrub_child_env(
{"RAFT_PROFILE": "dev"},
is_windows=False,
)
_on_session_start(session_id="session-1", turn_id="turn-1")
assert not is_env_passthrough("RAFT_PROFILE")
_on_session_start(platform="raft", session_id="session-1", turn_id="turn-1")
assert is_env_passthrough("RAFT_PROFILE")
assert _scrub_child_env({"RAFT_PROFILE": "dev"}, is_windows=False)["RAFT_PROFILE"] == "dev"
with patch.dict(os.environ, {"PATH": "/usr/bin", "RAFT_PROFILE": "dev"}, clear=True):
assert _make_run_env({})["RAFT_PROFILE"] == "dev"
finally:
clear_env_passthrough()
env_passthrough_mod._config_passthrough = previous_config_passthrough
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
def test_interrupted_turn_reports_error_stop(self):
adapter = _make_adapter()
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.add(adapter)
try:
_on_pre_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
)
_on_session_end(
platform="raft",
session_id="session-1",
turn_id="turn-1",
completed=False,
interrupted=True,
)
drain = adapter._activity_queue.drain(10)
finally:
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.discard(adapter)
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
assert [event["hookEventName"] for event in drain["events"]] == [
"UserPromptSubmit",
"Stop",
]
assert drain["events"][1]["status"] == "error"
assert drain["events"][1]["errorClass"] == "interrupted"
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_env_enablement_returns_none_without_profile(self, monkeypatch):
monkeypatch.delenv("RAFT_PROFILE", raising=False)
assert _env_enablement() is None
def test_is_connected_checks_bridge_token_or_enabled(self):
assert _is_connected(PlatformConfig(enabled=True, extra={"bridge_token": "tok"})) is True
assert _is_connected(PlatformConfig(enabled=True, extra={"enabled": True})) is True
assert _is_connected(PlatformConfig(enabled=True, extra={})) is False
def test_register_calls_register_platform(self):
registered = {}
hooks = {}
class FakeCtx:
def register_platform(self, **kwargs):
registered.update(kwargs)
def register_hook(self, name, handler):
hooks[name] = handler
register(FakeCtx())
assert registered["name"] == "raft"
assert registered["label"] == "Raft"
assert registered["emoji"] == "🔔"
assert "profile show" in registered["platform_hint"]
assert "manual get" in registered["platform_hint"]
assert "--profile" in registered["platform_hint"]
assert hooks == {
"on_session_start": _on_session_start,
"pre_llm_call": _on_pre_llm_call,
"pre_tool_call": _on_pre_tool_call,
"post_tool_call": _on_post_tool_call,
"post_llm_call": _on_post_llm_call,
"on_session_end": _on_session_end,
"on_session_finalize": _on_session_finalize,
}