Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
445
tests/gateway/test_proxy_mode.py
Normal file
445
tests/gateway/test_proxy_mode.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user