Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson
2026-04-14 18:26:05 -05:00
83 changed files with 5435 additions and 470 deletions

View File

@@ -93,6 +93,12 @@ def make_restart_runner(
runner._running_agent_count = GatewayRunner._running_agent_count.__get__(
runner, GatewayRunner
)
runner._snapshot_running_agents = GatewayRunner._snapshot_running_agents.__get__(
runner, GatewayRunner
)
runner._notify_active_sessions_of_shutdown = (
GatewayRunner._notify_active_sessions_of_shutdown.__get__(runner, GatewayRunner)
)
runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__(
runner, GatewayRunner
)

View File

@@ -220,6 +220,7 @@ def _create_app(adapter: APIServerAdapter) -> web.Application:
app = web.Application(middlewares=mws)
app["api_server_adapter"] = adapter
app.router.add_get("/health", adapter._handle_health)
app.router.add_get("/health/detailed", adapter._handle_health_detailed)
app.router.add_get("/v1/health", adapter._handle_health)
app.router.add_get("/v1/models", adapter._handle_models)
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
@@ -277,6 +278,58 @@ class TestHealthEndpoint:
assert data["platform"] == "hermes-agent"
# ---------------------------------------------------------------------------
# /health/detailed endpoint
# ---------------------------------------------------------------------------
class TestHealthDetailedEndpoint:
@pytest.mark.asyncio
async def test_health_detailed_returns_ok(self, adapter):
"""GET /health/detailed returns status, platform, and runtime fields."""
app = _create_app(adapter)
with patch("gateway.status.read_runtime_status", return_value={
"gateway_state": "running",
"platforms": {"telegram": {"state": "connected"}},
"active_agents": 2,
"exit_reason": None,
"updated_at": "2026-04-14T00:00:00Z",
}):
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health/detailed")
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
assert data["platform"] == "hermes-agent"
assert data["gateway_state"] == "running"
assert data["platforms"] == {"telegram": {"state": "connected"}}
assert data["active_agents"] == 2
assert isinstance(data["pid"], int)
assert "updated_at" in data
@pytest.mark.asyncio
async def test_health_detailed_no_runtime_status(self, adapter):
"""When gateway_state.json is missing, fields are None."""
app = _create_app(adapter)
with patch("gateway.status.read_runtime_status", return_value=None):
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health/detailed")
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
assert data["gateway_state"] is None
assert data["platforms"] == {}
@pytest.mark.asyncio
async def test_health_detailed_does_not_require_auth(self, auth_adapter):
"""Health detailed endpoint should be accessible without auth, like /health."""
app = _create_app(auth_adapter)
with patch("gateway.status.read_runtime_status", return_value=None):
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health/detailed")
assert resp.status == 200
# ---------------------------------------------------------------------------
# /v1/models endpoint
# ---------------------------------------------------------------------------

View File

@@ -167,6 +167,63 @@ class TestBlueBubblesWebhookParsing:
chat_identifier = sender
assert chat_identifier == "user@example.com"
def test_webhook_extracts_chat_guid_from_chats_array_dm(self, monkeypatch):
"""BB v1.9+ webhook payloads omit top-level chatGuid; GUID is in chats[0].guid."""
adapter = _make_adapter(monkeypatch)
payload = {
"type": "new-message",
"data": {
"guid": "MESSAGE-GUID",
"text": "hello",
"handle": {"address": "+15551234567"},
"isFromMe": False,
"chats": [
{"guid": "any;-;+15551234567", "chatIdentifier": "+15551234567"}
],
},
}
record = adapter._extract_payload_record(payload) or {}
chat_guid = adapter._value(
record.get("chatGuid"),
payload.get("chatGuid"),
record.get("chat_guid"),
payload.get("chat_guid"),
payload.get("guid"),
)
if not chat_guid:
_chats = record.get("chats") or []
if _chats and isinstance(_chats[0], dict):
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
assert chat_guid == "any;-;+15551234567"
def test_webhook_extracts_chat_guid_from_chats_array_group(self, monkeypatch):
"""Group chat GUIDs contain ;+; and must be extracted from chats array."""
adapter = _make_adapter(monkeypatch)
payload = {
"type": "new-message",
"data": {
"guid": "MESSAGE-GUID",
"text": "hello everyone",
"handle": {"address": "+15551234567"},
"isFromMe": False,
"isGroup": True,
"chats": [{"guid": "any;+;chat-uuid-abc123"}],
},
}
record = adapter._extract_payload_record(payload) or {}
chat_guid = adapter._value(
record.get("chatGuid"),
payload.get("chatGuid"),
record.get("chat_guid"),
payload.get("chat_guid"),
payload.get("guid"),
)
if not chat_guid:
_chats = record.get("chats") or []
if _chats and isinstance(_chats[0], dict):
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
assert chat_guid == "any;+;chat-uuid-abc123"
def test_extract_payload_record_accepts_list_data(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
payload = {
@@ -385,6 +442,28 @@ class TestBlueBubblesWebhookUrl:
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
assert "192.168.1.50" in adapter._webhook_url
def test_register_url_embeds_password(self, monkeypatch):
"""_webhook_register_url should append ?password=... for inbound auth."""
adapter = _make_adapter(monkeypatch, password="secret123")
assert adapter._webhook_register_url.endswith("?password=secret123")
assert adapter._webhook_register_url.startswith(adapter._webhook_url)
def test_register_url_url_encodes_password(self, monkeypatch):
"""Passwords with special characters must be URL-encoded."""
adapter = _make_adapter(monkeypatch, password="W9fTC&L5JL*@")
assert "password=W9fTC%26L5JL%2A%40" in adapter._webhook_register_url
def test_register_url_omits_query_when_no_password(self, monkeypatch):
"""If no password is configured, the register URL should be the bare URL."""
monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False)
from gateway.platforms.bluebubbles import BlueBubblesAdapter
cfg = PlatformConfig(
enabled=True,
extra={"server_url": "http://localhost:1234", "password": ""},
)
adapter = BlueBubblesAdapter(cfg)
assert adapter._webhook_register_url == adapter._webhook_url
class TestBlueBubblesWebhookRegistration:
"""Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks."""
@@ -500,7 +579,7 @@ class TestBlueBubblesWebhookRegistration:
"""Crash resilience — existing registration is reused, no POST needed."""
import asyncio
adapter = _make_adapter(monkeypatch)
url = adapter._webhook_url
url = adapter._webhook_register_url
adapter.client = self._mock_client(
get_response={"status": 200, "data": [
{"id": 7, "url": url, "events": ["new-message"]},
@@ -548,7 +627,7 @@ class TestBlueBubblesWebhookRegistration:
def test_unregister_removes_matching(self, monkeypatch):
import asyncio
adapter = _make_adapter(monkeypatch)
url = adapter._webhook_url
url = adapter._webhook_register_url
adapter.client = self._mock_client(
get_response={"status": 200, "data": [
{"id": 10, "url": url},
@@ -563,7 +642,7 @@ class TestBlueBubblesWebhookRegistration:
"""Multiple orphaned registrations for same URL — all get removed."""
import asyncio
adapter = _make_adapter(monkeypatch)
url = adapter._webhook_url
url = adapter._webhook_register_url
deleted_ids = []
async def mock_delete(*args, **kwargs):

View File

@@ -4,9 +4,12 @@ Covers the threading behavior control for multi-chunk replies:
- "off": Never reply-reference to original message
- "first": Only first chunk uses reply reference (default)
- "all": All chunks reply-reference the original message
Also covers reply_to_text extraction from incoming messages.
"""
import os
import sys
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock, AsyncMock, patch
@@ -275,3 +278,107 @@ class TestEnvVarOverride:
_apply_env_overrides(config)
assert Platform.DISCORD in config.platforms
assert config.platforms[Platform.DISCORD].reply_to_mode == "off"
# ------------------------------------------------------------------
# Tests for reply_to_text extraction in _handle_message
# ------------------------------------------------------------------
class FakeDMChannel:
"""Minimal DM channel stub (skips mention / channel-allow checks)."""
def __init__(self, channel_id: int = 100, name: str = "dm"):
self.id = channel_id
self.name = name
def _make_message(*, content: str = "hi", reference=None):
"""Build a mock Discord message for _handle_message tests."""
author = SimpleNamespace(id=42, display_name="TestUser", name="TestUser")
return SimpleNamespace(
id=999,
content=content,
mentions=[],
attachments=[],
reference=reference,
created_at=datetime.now(timezone.utc),
channel=FakeDMChannel(),
author=author,
)
@pytest.fixture
def reply_text_adapter(monkeypatch):
"""DiscordAdapter wired for _handle_message → handle_message capture."""
import gateway.platforms.discord as discord_platform
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
adapter._text_batch_delay_seconds = 0
adapter.handle_message = AsyncMock()
return adapter
class TestReplyToText:
"""Tests for reply_to_text populated by _handle_message."""
@pytest.mark.asyncio
async def test_no_reference_both_none(self, reply_text_adapter):
message = _make_message(reference=None)
await reply_text_adapter._handle_message(message)
event = reply_text_adapter.handle_message.await_args.args[0]
assert event.reply_to_message_id is None
assert event.reply_to_text is None
@pytest.mark.asyncio
async def test_reference_without_resolved(self, reply_text_adapter):
ref = SimpleNamespace(message_id=555, resolved=None)
message = _make_message(reference=ref)
await reply_text_adapter._handle_message(message)
event = reply_text_adapter.handle_message.await_args.args[0]
assert event.reply_to_message_id == "555"
assert event.reply_to_text is None
@pytest.mark.asyncio
async def test_reference_with_resolved_content(self, reply_text_adapter):
resolved_msg = SimpleNamespace(content="original message text")
ref = SimpleNamespace(message_id=555, resolved=resolved_msg)
message = _make_message(reference=ref)
await reply_text_adapter._handle_message(message)
event = reply_text_adapter.handle_message.await_args.args[0]
assert event.reply_to_message_id == "555"
assert event.reply_to_text == "original message text"
@pytest.mark.asyncio
async def test_reference_with_empty_resolved_content(self, reply_text_adapter):
"""Empty string content should become None, not leak as empty string."""
resolved_msg = SimpleNamespace(content="")
ref = SimpleNamespace(message_id=555, resolved=resolved_msg)
message = _make_message(reference=ref)
await reply_text_adapter._handle_message(message)
event = reply_text_adapter.handle_message.await_args.args[0]
assert event.reply_to_message_id == "555"
assert event.reply_to_text is None
@pytest.mark.asyncio
async def test_reference_with_deleted_message(self, reply_text_adapter):
"""Deleted messages lack .content — getattr guard should return None."""
resolved_deleted = SimpleNamespace(id=555)
ref = SimpleNamespace(message_id=555, resolved=resolved_deleted)
message = _make_message(reference=ref)
await reply_text_adapter._handle_message(message)
event = reply_text_adapter.handle_message.await_args.args[0]
assert event.reply_to_message_id == "555"
assert event.reply_to_text is None

View File

@@ -297,6 +297,15 @@ class TestStreamingPerPlatform:
result = resolve_display_setting(config, "telegram", "streaming")
assert result is None # caller should check global StreamingConfig
def test_global_display_streaming_is_cli_only(self):
"""display.streaming must not act as a gateway streaming override."""
from gateway.display_config import resolve_display_setting
for value in (True, False):
config = {"display": {"streaming": value}}
assert resolve_display_setting(config, "telegram", "streaming") is None
assert resolve_display_setting(config, "discord", "streaming") is None
def test_explicit_false_disables(self):
"""Explicit False disables streaming for that platform."""
from gateway.display_config import resolve_display_setting

View File

@@ -1,12 +1,11 @@
"""Tests for Feishu interactive card approval buttons."""
import asyncio
import importlib.util
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -23,14 +22,14 @@ if _repo not in sys.path:
# ---------------------------------------------------------------------------
def _ensure_feishu_mocks():
"""Provide stubs for lark-oapi / aiohttp.web so the import succeeds."""
if "lark_oapi" not in sys.modules:
if importlib.util.find_spec("lark_oapi") is None and "lark_oapi" not in sys.modules:
mod = MagicMock()
for name in (
"lark_oapi", "lark_oapi.api.im.v1",
"lark_oapi.event", "lark_oapi.event.callback_type",
):
sys.modules.setdefault(name, mod)
if "aiohttp" not in sys.modules:
if importlib.util.find_spec("aiohttp") is None and "aiohttp" not in sys.modules:
aio = MagicMock()
sys.modules.setdefault("aiohttp", aio)
sys.modules.setdefault("aiohttp.web", aio.web)
@@ -39,6 +38,7 @@ def _ensure_feishu_mocks():
_ensure_feishu_mocks()
from gateway.config import PlatformConfig
import gateway.platforms.feishu as feishu_module
from gateway.platforms.feishu import FeishuAdapter
@@ -74,6 +74,12 @@ def _make_card_action_data(
)
def _close_submitted_coro(coro, _loop):
"""Close scheduled coroutines in sync-handler tests to avoid unawaited warnings."""
coro.close()
return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)
# ===========================================================================
# send_exec_approval — interactive card with buttons
# ===========================================================================
@@ -203,14 +209,14 @@ class TestFeishuExecApproval:
# ===========================================================================
# _handle_card_action_event — approval button clicks
# _resolve_approval — approval state pop + gateway resolution
# ===========================================================================
class TestFeishuApprovalCallback:
"""Test the approval intercept in _handle_card_action_event."""
class TestResolveApproval:
"""Test _resolve_approval pops state and calls resolve_gateway_approval."""
@pytest.mark.asyncio
async def test_resolves_approval_on_click(self):
async def test_resolves_once(self):
adapter = _make_adapter()
adapter._approval_state[1] = {
"session_key": "agent:main:feishu:group:oc_12345",
@@ -218,28 +224,14 @@ class TestFeishuApprovalCallback:
"chat_id": "oc_12345",
}
data = _make_card_action_data(
action_value={"hermes_action": "approve_once", "approval_id": 1},
)
with (
patch.object(
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
return_value={"user_id": "ou_user1", "user_name": "Norbert", "user_id_alt": None},
),
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
):
await adapter._handle_card_action_event(data)
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(1, "once", "Norbert")
mock_resolve.assert_called_once_with("agent:main:feishu:group:oc_12345", "once")
mock_update.assert_called_once_with("msg_001", "Approved once", "Norbert", "once")
# State should be cleaned up
assert 1 not in adapter._approval_state
@pytest.mark.asyncio
async def test_deny_button(self):
async def test_resolves_deny(self):
adapter = _make_adapter()
adapter._approval_state[2] = {
"session_key": "some-session",
@@ -247,26 +239,13 @@ class TestFeishuApprovalCallback:
"chat_id": "oc_12345",
}
data = _make_card_action_data(
action_value={"hermes_action": "deny", "approval_id": 2},
token="tok_deny",
)
with (
patch.object(
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
return_value={"user_id": "ou_alice", "user_name": "Alice", "user_id_alt": None},
),
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
):
await adapter._handle_card_action_event(data)
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(2, "deny", "Alice")
mock_resolve.assert_called_once_with("some-session", "deny")
mock_update.assert_called_once_with("msg_002", "Denied", "Alice", "deny")
@pytest.mark.asyncio
async def test_session_approval(self):
async def test_resolves_session(self):
adapter = _make_adapter()
adapter._approval_state[3] = {
"session_key": "sess-3",
@@ -274,26 +253,13 @@ class TestFeishuApprovalCallback:
"chat_id": "oc_99",
}
data = _make_card_action_data(
action_value={"hermes_action": "approve_session", "approval_id": 3},
token="tok_ses",
)
with (
patch.object(
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
return_value={"user_id": "ou_u", "user_name": "Bob", "user_id_alt": None},
),
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
):
await adapter._handle_card_action_event(data)
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(3, "session", "Bob")
mock_resolve.assert_called_once_with("sess-3", "session")
mock_update.assert_called_once_with("msg_003", "Approved for session", "Bob", "session")
@pytest.mark.asyncio
async def test_always_approval(self):
async def test_resolves_always(self):
adapter = _make_adapter()
adapter._approval_state[4] = {
"session_key": "sess-4",
@@ -301,42 +267,29 @@ class TestFeishuApprovalCallback:
"chat_id": "oc_55",
}
data = _make_card_action_data(
action_value={"hermes_action": "approve_always", "approval_id": 4},
token="tok_alw",
)
with (
patch.object(
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
return_value={"user_id": "ou_u", "user_name": "Carol", "user_id_alt": None},
),
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock),
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
):
await adapter._handle_card_action_event(data)
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._resolve_approval(4, "always", "Carol")
mock_resolve.assert_called_once_with("sess-4", "always")
@pytest.mark.asyncio
async def test_already_resolved_drops_silently(self):
adapter = _make_adapter()
# No state for approval_id 99 — already resolved
data = _make_card_action_data(
action_value={"hermes_action": "approve_once", "approval_id": 99},
token="tok_gone",
)
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
await adapter._handle_card_action_event(data)
await adapter._resolve_approval(99, "once", "Nobody")
# Should NOT resolve — already handled
mock_resolve.assert_not_called()
# ===========================================================================
# _handle_card_action_event — non-approval card actions
# ===========================================================================
class TestNonApprovalCardAction:
"""Non-approval card actions should still route as synthetic commands."""
@pytest.mark.asyncio
async def test_non_approval_actions_route_normally(self):
"""Non-approval card actions should still become synthetic commands."""
async def test_routes_as_synthetic_command(self):
adapter = _make_adapter()
data = _make_card_action_data(
@@ -351,82 +304,141 @@ class TestFeishuApprovalCallback:
),
patch.object(adapter, "get_chat_info", new_callable=AsyncMock, return_value={"name": "Test Chat"}),
patch.object(adapter, "_handle_message_with_guards", new_callable=AsyncMock) as mock_handle,
patch("tools.approval.resolve_gateway_approval") as mock_resolve,
):
await adapter._handle_card_action_event(data)
# Should NOT resolve any approval
mock_resolve.assert_not_called()
# Should have routed as synthetic command
mock_handle.assert_called_once()
event = mock_handle.call_args[0][0]
assert "/card button" in event.text
# ===========================================================================
# _update_approval_card — card replacement after resolution
# _on_card_action_trigger — inline card response for approval actions
# ===========================================================================
class TestFeishuUpdateApprovalCard:
"""Test the card update after approval resolution."""
class _FakeCallBackCard:
def __init__(self):
self.type = None
self.data = None
@pytest.mark.asyncio
async def test_updates_card_on_approve(self):
class _FakeP2Response:
def __init__(self):
self.card = None
@pytest.fixture(autouse=False)
def _patch_callback_card_types(monkeypatch):
"""Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests."""
monkeypatch.setattr(feishu_module, "P2CardActionTriggerResponse", _FakeP2Response)
monkeypatch.setattr(feishu_module, "CallBackCard", _FakeCallBackCard)
class TestCardActionCallbackResponse:
"""Test that _on_card_action_trigger returns updated card inline."""
def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = None
data = _make_card_action_data({"hermes_action": "approve_once", "approval_id": 1})
mock_update = AsyncMock()
adapter._client.im.v1.message.update = MagicMock()
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
response = adapter._on_card_action_trigger(data)
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
await adapter._update_approval_card(
"msg_001", "Approved once", "Norbert", "once"
)
assert response is not None
assert response.card is None
mock_submit.assert_not_called()
mock_thread.assert_called_once()
# Verify the update request was built
call_args = mock_thread.call_args
assert call_args[0][0] == adapter._client.im.v1.message.update
@pytest.mark.asyncio
async def test_updates_card_on_deny(self):
def test_returns_card_for_approve_action(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "approve_once", "approval_id": 1},
open_id="ou_bob",
)
adapter._sender_name_cache["ou_bob"] = ("Bob", 9999999999)
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
await adapter._update_approval_card(
"msg_002", "Denied", "Alice", "deny"
)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
mock_thread.assert_called_once()
assert response is not None
assert response.card is not None
assert response.card.type == "raw"
card = response.card.data
assert card["header"]["template"] == "green"
assert "Approved once" in card["header"]["title"]["content"]
assert "Bob" in card["elements"][0]["content"]
@pytest.mark.asyncio
async def test_skips_update_when_not_connected(self):
def test_returns_card_for_deny_action(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._client = None
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "deny", "approval_id": 2},
)
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
await adapter._update_approval_card(
"msg_001", "Approved", "Bob", "once"
)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
mock_thread.assert_not_called()
assert response.card is not None
card = response.card.data
assert card["header"]["template"] == "red"
assert "Denied" in card["header"]["title"]["content"]
@pytest.mark.asyncio
async def test_skips_update_when_no_message_id(self):
def test_ignores_missing_approval_id(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data({"hermes_action": "approve_once"})
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
await adapter._update_approval_card(
"", "Approved", "Bob", "once"
)
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
response = adapter._on_card_action_trigger(data)
mock_thread.assert_not_called()
assert response is not None
assert response.card is None
mock_submit.assert_not_called()
@pytest.mark.asyncio
async def test_swallows_update_errors(self):
def test_no_card_for_non_approval_action(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data({"some_other": "value"})
with patch("asyncio.to_thread", new_callable=AsyncMock, side_effect=Exception("API error")):
# Should not raise
await adapter._update_approval_card(
"msg_001", "Approved", "Bob", "once"
)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
assert response is not None
assert response.card is None
def test_falls_back_to_open_id_when_name_not_cached(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "approve_session", "approval_id": 3},
open_id="ou_unknown",
)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
card = response.card.data
assert "ou_unknown" in card["elements"][0]["content"]
def test_ignores_expired_cached_name(self, _patch_callback_card_types):
adapter = _make_adapter()
adapter._loop = MagicMock()
adapter._loop.is_closed = MagicMock(return_value=False)
data = _make_card_action_data(
{"hermes_action": "approve_once", "approval_id": 4},
open_id="ou_expired",
)
adapter._sender_name_cache["ou_expired"] = ("Old Name", 1)
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
response = adapter._on_card_action_trigger(data)
card = response.card.data
assert "Old Name" not in card["elements"][0]["content"]
assert "ou_expired" in card["elements"][0]["content"]

View File

@@ -0,0 +1,445 @@
"""Tests for gateway proxy mode — forwarding messages to a remote API server."""
import asyncio
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform, StreamingConfig
from gateway.run import GatewayRunner
from gateway.session import SessionSource
def _make_runner(proxy_url=None):
"""Create a minimal GatewayRunner for proxy tests."""
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner.config = MagicMock()
runner.config.streaming = StreamingConfig()
runner._running_agents = {}
runner._session_model_overrides = {}
runner._agent_cache = {}
runner._agent_cache_lock = None
return runner
def _make_source(platform=Platform.MATRIX):
return SessionSource(
platform=platform,
chat_id="!room:server.org",
chat_name="Test Room",
chat_type="group",
user_id="@user:server.org",
user_name="testuser",
thread_id=None,
)
class _FakeSSEResponse:
"""Simulates an aiohttp response with SSE streaming."""
def __init__(self, status=200, sse_chunks=None, error_text=""):
self.status = status
self._sse_chunks = sse_chunks or []
self._error_text = error_text
self.content = self
async def text(self):
return self._error_text
async def iter_any(self):
for chunk in self._sse_chunks:
if isinstance(chunk, str):
chunk = chunk.encode("utf-8")
yield chunk
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
class _FakeSession:
"""Simulates an aiohttp.ClientSession with captured request args."""
def __init__(self, response):
self._response = response
self.captured_url = None
self.captured_json = None
self.captured_headers = None
def post(self, url, json=None, headers=None, **kwargs):
self.captured_url = url
self.captured_json = json
self.captured_headers = headers
return self._response
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def _patch_aiohttp(session):
"""Patch aiohttp.ClientSession to return our fake session."""
return patch(
"aiohttp.ClientSession",
return_value=session,
)
class TestGetProxyUrl:
"""Test _get_proxy_url() config resolution."""
def test_returns_none_when_not_configured(self, monkeypatch):
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
runner = _make_runner()
with patch("gateway.run._load_gateway_config", return_value={}):
assert runner._get_proxy_url() is None
def test_reads_from_env_var(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://192.168.1.100:8642")
runner = _make_runner()
assert runner._get_proxy_url() == "http://192.168.1.100:8642"
def test_strips_trailing_slash(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642/")
runner = _make_runner()
assert runner._get_proxy_url() == "http://host:8642"
def test_reads_from_config_yaml(self, monkeypatch):
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
runner = _make_runner()
cfg = {"gateway": {"proxy_url": "http://10.0.0.1:8642"}}
with patch("gateway.run._load_gateway_config", return_value=cfg):
assert runner._get_proxy_url() == "http://10.0.0.1:8642"
def test_env_var_overrides_config(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://env-host:8642")
runner = _make_runner()
cfg = {"gateway": {"proxy_url": "http://config-host:8642"}}
with patch("gateway.run._load_gateway_config", return_value=cfg):
assert runner._get_proxy_url() == "http://env-host:8642"
def test_empty_string_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", " ")
runner = _make_runner()
with patch("gateway.run._load_gateway_config", return_value={}):
assert runner._get_proxy_url() is None
class TestRunAgentProxyDispatch:
"""Test that _run_agent() delegates to proxy when configured."""
@pytest.mark.asyncio
async def test_run_agent_delegates_to_proxy(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
runner = _make_runner()
source = _make_source()
expected_result = {
"final_response": "Hello from remote!",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello from remote!"},
],
"api_calls": 1,
"tools": [],
}
runner._run_agent_via_proxy = AsyncMock(return_value=expected_result)
result = await runner._run_agent(
message="hi",
context_prompt="",
history=[],
source=source,
session_id="test-session-123",
session_key="test-key",
)
assert result["final_response"] == "Hello from remote!"
runner._run_agent_via_proxy.assert_called_once()
@pytest.mark.asyncio
async def test_run_agent_skips_proxy_when_not_configured(self, monkeypatch):
monkeypatch.delenv("GATEWAY_PROXY_URL", raising=False)
runner = _make_runner()
runner._run_agent_via_proxy = AsyncMock()
with patch("gateway.run._load_gateway_config", return_value={}):
try:
await runner._run_agent(
message="hi",
context_prompt="",
history=[],
source=_make_source(),
session_id="test-session",
)
except Exception:
pass # Expected — bare runner can't create a real agent
runner._run_agent_via_proxy.assert_not_called()
class TestRunAgentViaProxy:
"""Test the actual proxy HTTP forwarding logic."""
@pytest.mark.asyncio
async def test_builds_correct_request(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
monkeypatch.setenv("GATEWAY_PROXY_KEY", "test-key-123")
runner = _make_runner()
source = _make_source()
resp = _FakeSSEResponse(
status=200,
sse_chunks=[
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n'
"data: [DONE]\n\n"
],
)
session = _FakeSession(resp)
with patch("gateway.run._load_gateway_config", return_value={}):
with _patch_aiohttp(session):
with patch("aiohttp.ClientTimeout"):
result = await runner._run_agent_via_proxy(
message="How are you?",
context_prompt="You are helpful.",
history=[
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
],
source=source,
session_id="session-abc",
)
# Verify request URL
assert session.captured_url == "http://host:8642/v1/chat/completions"
# Verify auth header
assert session.captured_headers["Authorization"] == "Bearer test-key-123"
# Verify session ID header
assert session.captured_headers["X-Hermes-Session-Id"] == "session-abc"
# Verify messages include system, history, and current message
messages = session.captured_json["messages"]
assert messages[0] == {"role": "system", "content": "You are helpful."}
assert messages[1] == {"role": "user", "content": "Hello"}
assert messages[2] == {"role": "assistant", "content": "Hi there!"}
assert messages[3] == {"role": "user", "content": "How are you?"}
# Verify streaming is requested
assert session.captured_json["stream"] is True
# Verify response was assembled
assert result["final_response"] == "Hello world"
@pytest.mark.asyncio
async def test_handles_http_error(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
runner = _make_runner()
source = _make_source()
resp = _FakeSSEResponse(status=401, error_text="Unauthorized: invalid API key")
session = _FakeSession(resp)
with patch("gateway.run._load_gateway_config", return_value={}):
with _patch_aiohttp(session):
with patch("aiohttp.ClientTimeout"):
result = await runner._run_agent_via_proxy(
message="hi",
context_prompt="",
history=[],
source=source,
session_id="test",
)
assert "Proxy error (401)" in result["final_response"]
assert result["api_calls"] == 0
@pytest.mark.asyncio
async def test_handles_connection_error(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://unreachable:8642")
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
runner = _make_runner()
source = _make_source()
class _ErrorSession:
def post(self, *args, **kwargs):
raise ConnectionError("Connection refused")
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
with patch("gateway.run._load_gateway_config", return_value={}):
with patch("aiohttp.ClientSession", return_value=_ErrorSession()):
with patch("aiohttp.ClientTimeout"):
result = await runner._run_agent_via_proxy(
message="hi",
context_prompt="",
history=[],
source=source,
session_id="test",
)
assert "Proxy connection error" in result["final_response"]
@pytest.mark.asyncio
async def test_skips_tool_messages_in_history(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
runner = _make_runner()
source = _make_source()
resp = _FakeSSEResponse(
status=200,
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
)
session = _FakeSession(resp)
history = [
{"role": "user", "content": "search for X"},
{"role": "assistant", "content": None, "tool_calls": [{"id": "tc1"}]},
{"role": "tool", "content": "search results...", "tool_call_id": "tc1"},
{"role": "assistant", "content": "Found results."},
]
with patch("gateway.run._load_gateway_config", return_value={}):
with _patch_aiohttp(session):
with patch("aiohttp.ClientTimeout"):
await runner._run_agent_via_proxy(
message="tell me more",
context_prompt="",
history=history,
source=source,
session_id="test",
)
# Only user and assistant with content should be forwarded
messages = session.captured_json["messages"]
roles = [m["role"] for m in messages]
assert "tool" not in roles
# assistant with None content should be skipped
assert all(m.get("content") for m in messages)
@pytest.mark.asyncio
async def test_result_shape_matches_run_agent(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
runner = _make_runner()
source = _make_source()
resp = _FakeSSEResponse(
status=200,
sse_chunks=[b'data: {"choices":[{"delta":{"content":"answer"}}]}\n\ndata: [DONE]\n\n'],
)
session = _FakeSession(resp)
with patch("gateway.run._load_gateway_config", return_value={}):
with _patch_aiohttp(session):
with patch("aiohttp.ClientTimeout"):
result = await runner._run_agent_via_proxy(
message="hi",
context_prompt="",
history=[{"role": "user", "content": "prev"}, {"role": "assistant", "content": "ok"}],
source=source,
session_id="sess-123",
)
# Required keys that callers depend on
assert "final_response" in result
assert result["final_response"] == "answer"
assert "messages" in result
assert "api_calls" in result
assert "tools" in result
assert "history_offset" in result
assert result["history_offset"] == 2 # len(history)
assert "session_id" in result
assert result["session_id"] == "sess-123"
@pytest.mark.asyncio
async def test_no_auth_header_without_key(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
runner = _make_runner()
source = _make_source()
resp = _FakeSSEResponse(
status=200,
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
)
session = _FakeSession(resp)
with patch("gateway.run._load_gateway_config", return_value={}):
with _patch_aiohttp(session):
with patch("aiohttp.ClientTimeout"):
await runner._run_agent_via_proxy(
message="hi",
context_prompt="",
history=[],
source=source,
session_id="test",
)
assert "Authorization" not in session.captured_headers
@pytest.mark.asyncio
async def test_no_system_message_when_context_empty(self, monkeypatch):
monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642")
monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False)
runner = _make_runner()
source = _make_source()
resp = _FakeSSEResponse(
status=200,
sse_chunks=[b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'],
)
session = _FakeSession(resp)
with patch("gateway.run._load_gateway_config", return_value={}):
with _patch_aiohttp(session):
with patch("aiohttp.ClientTimeout"):
await runner._run_agent_via_proxy(
message="hello",
context_prompt="",
history=[],
source=source,
session_id="test",
)
# No system message should appear when context_prompt is empty
messages = session.captured_json["messages"]
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "hello"
class TestEnvVarRegistration:
"""Verify GATEWAY_PROXY_URL and GATEWAY_PROXY_KEY are registered."""
def test_proxy_url_in_optional_env_vars(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
assert "GATEWAY_PROXY_URL" in OPTIONAL_ENV_VARS
info = OPTIONAL_ENV_VARS["GATEWAY_PROXY_URL"]
assert info["category"] == "messaging"
assert info["password"] is False
def test_proxy_key_in_optional_env_vars(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
assert "GATEWAY_PROXY_KEY" in OPTIONAL_ENV_VARS
info = OPTIONAL_ENV_VARS["GATEWAY_PROXY_KEY"]
assert info["category"] == "messaging"
assert info["password"] is True

View File

@@ -161,3 +161,84 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
assert kwargs["start_new_session"] is True
assert kwargs["stdout"] is subprocess.DEVNULL
assert kwargs["stderr"] is subprocess.DEVNULL
# ── Shutdown notification tests ──────────────────────────────────────
@pytest.mark.asyncio
async def test_shutdown_notification_sent_to_active_sessions():
"""Active sessions receive a notification when the gateway starts shutting down."""
runner, adapter = make_restart_runner()
source = make_restart_source(chat_id="999", chat_type="dm")
session_key = f"agent:main:telegram:dm:999"
runner._running_agents[session_key] = MagicMock()
await runner._notify_active_sessions_of_shutdown()
assert len(adapter.sent) == 1
assert "shutting down" in adapter.sent[0]
assert "interrupted" in adapter.sent[0]
@pytest.mark.asyncio
async def test_shutdown_notification_says_restarting_when_restart_requested():
"""When _restart_requested is True, the message says 'restarting' and mentions /retry."""
runner, adapter = make_restart_runner()
runner._restart_requested = True
session_key = "agent:main:telegram:dm:999"
runner._running_agents[session_key] = MagicMock()
await runner._notify_active_sessions_of_shutdown()
assert len(adapter.sent) == 1
assert "restarting" in adapter.sent[0]
assert "/retry" in adapter.sent[0]
@pytest.mark.asyncio
async def test_shutdown_notification_deduplicates_per_chat():
"""Multiple sessions in the same chat only get one notification."""
runner, adapter = make_restart_runner()
# Two sessions (different users) in the same chat
runner._running_agents["agent:main:telegram:group:chat1:u1"] = MagicMock()
runner._running_agents["agent:main:telegram:group:chat1:u2"] = MagicMock()
await runner._notify_active_sessions_of_shutdown()
assert len(adapter.sent) == 1
@pytest.mark.asyncio
async def test_shutdown_notification_skipped_when_no_active_agents():
"""No notification is sent when there are no active agents."""
runner, adapter = make_restart_runner()
await runner._notify_active_sessions_of_shutdown()
assert len(adapter.sent) == 0
@pytest.mark.asyncio
async def test_shutdown_notification_ignores_pending_sentinels():
"""Pending sentinels (not-yet-started agents) don't trigger notifications."""
from gateway.run import _AGENT_PENDING_SENTINEL
runner, adapter = make_restart_runner()
runner._running_agents["agent:main:telegram:dm:999"] = _AGENT_PENDING_SENTINEL
await runner._notify_active_sessions_of_shutdown()
assert len(adapter.sent) == 0
@pytest.mark.asyncio
async def test_shutdown_notification_send_failure_does_not_block():
"""If sending a notification fails, the method still completes."""
runner, adapter = make_restart_runner()
adapter.send = AsyncMock(side_effect=Exception("network error"))
session_key = "agent:main:telegram:dm:999"
runner._running_agents[session_key] = MagicMock()
# Should not raise
await runner._notify_active_sessions_of_shutdown()

View File

@@ -572,6 +572,27 @@ async def test_run_agent_streaming_does_not_enable_completed_interim_commentary(
assert not any(call["content"] == "I'll inspect the repo first." for call in adapter.sent)
@pytest.mark.asyncio
async def test_display_streaming_does_not_enable_gateway_streaming(monkeypatch, tmp_path):
adapter, result = await _run_with_agent(
monkeypatch,
tmp_path,
CommentaryAgent,
session_id="sess-display-streaming-cli-only",
config_data={
"display": {
"streaming": True,
"interim_assistant_messages": True,
},
"streaming": {"enabled": False},
},
)
assert result.get("already_sent") is not True
assert adapter.edits == []
assert [call["content"] for call in adapter.sent] == ["I'll inspect the repo first."]
@pytest.mark.asyncio
async def test_run_agent_interim_commentary_works_with_tool_progress_off(monkeypatch, tmp_path):
adapter, result = await _run_with_agent(

View File

@@ -408,6 +408,27 @@ class TestFormatMessageBlockquote:
result = adapter.format_message("5 > 3")
assert "\\>" in result
def test_expandable_blockquote(self, adapter):
"""Expandable blockquote prefix **> and trailing || must NOT be escaped."""
result = adapter.format_message("**> Hidden content||")
assert "**>" in result
assert "||" in result
assert "\\*" not in result # asterisks in prefix must not be escaped
assert "\\>" not in result # > in prefix must not be escaped
def test_single_asterisk_gt_not_blockquote(self, adapter):
"""Single asterisk before > should not be treated as blockquote prefix."""
result = adapter.format_message("*> not a quote")
assert "\\*" in result
assert "\\>" in result
def test_regular_blockquote_with_pipes_escaped(self, adapter):
"""Regular blockquote ending with || should escape the pipes."""
result = adapter.format_message("> not expandable||")
assert "> not expandable" in result
assert "\\|" in result
assert "\\>" not in result
# =========================================================================
# format_message - mixed/complex