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

This commit is contained in:
Brooklyn Nicholson
2026-04-13 18:32:13 -05:00
220 changed files with 23482 additions and 1959 deletions

View File

@@ -35,6 +35,7 @@ def make_restart_source(chat_id: str = "123456", chat_type: str = "dm") -> Sessi
platform=Platform.TELEGRAM,
chat_id=chat_id,
chat_type=chat_type,
user_id="u1",
)

View File

@@ -0,0 +1,87 @@
"""Tests for _normalize_chat_content in the API server adapter."""
from gateway.platforms.api_server import _normalize_chat_content
class TestNormalizeChatContent:
"""Content normalization converts array-based content parts to plain text."""
def test_none_returns_empty_string(self):
assert _normalize_chat_content(None) == ""
def test_plain_string_returned_as_is(self):
assert _normalize_chat_content("hello world") == "hello world"
def test_empty_string_returned_as_is(self):
assert _normalize_chat_content("") == ""
def test_text_content_part(self):
content = [{"type": "text", "text": "hello"}]
assert _normalize_chat_content(content) == "hello"
def test_input_text_content_part(self):
content = [{"type": "input_text", "text": "user input"}]
assert _normalize_chat_content(content) == "user input"
def test_output_text_content_part(self):
content = [{"type": "output_text", "text": "assistant output"}]
assert _normalize_chat_content(content) == "assistant output"
def test_multiple_text_parts_joined_with_newline(self):
content = [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
]
assert _normalize_chat_content(content) == "first\nsecond"
def test_mixed_string_and_dict_parts(self):
content = ["plain string", {"type": "text", "text": "dict part"}]
assert _normalize_chat_content(content) == "plain string\ndict part"
def test_image_url_parts_silently_skipped(self):
content = [
{"type": "text", "text": "check this:"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
]
assert _normalize_chat_content(content) == "check this:"
def test_integer_content_converted(self):
assert _normalize_chat_content(42) == "42"
def test_boolean_content_converted(self):
assert _normalize_chat_content(True) == "True"
def test_deeply_nested_list_respects_depth_limit(self):
"""Nesting beyond max_depth returns empty string."""
content = [[[[[[[[[[[["deep"]]]]]]]]]]]]
result = _normalize_chat_content(content)
# The deep nesting should be truncated, not crash
assert isinstance(result, str)
def test_large_list_capped(self):
"""Lists beyond MAX_CONTENT_LIST_SIZE are truncated."""
content = [{"type": "text", "text": f"item{i}"} for i in range(2000)]
result = _normalize_chat_content(content)
# Should not contain all 2000 items
assert result.count("item") <= 1000
def test_oversized_string_truncated(self):
"""Strings beyond 64KB are truncated."""
huge = "x" * 100_000
result = _normalize_chat_content(huge)
assert len(result) == 65_536
def test_empty_text_parts_filtered(self):
content = [
{"type": "text", "text": ""},
{"type": "text", "text": "actual"},
{"type": "text", "text": ""},
]
assert _normalize_chat_content(content) == "actual"
def test_dict_without_type_skipped(self):
content = [{"foo": "bar"}, {"type": "text", "text": "real"}]
assert _normalize_chat_content(content) == "real"
def test_empty_list_returns_empty(self):
assert _normalize_chat_content([]) == ""

View File

@@ -359,3 +359,44 @@ async def test_discord_thread_participation_tracked_on_dispatch(adapter, monkeyp
await adapter._handle_message(message)
assert "777" in adapter._threads
@pytest.mark.asyncio
async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_thread(adapter, monkeypatch):
"""Active voice-linked text channels should behave like free-response channels."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
adapter._voice_text_channels[111] = 789
adapter._auto_create_thread = AsyncMock()
message = make_message(
channel=FakeTextChannel(channel_id=789),
content="follow-up from voice text chat",
)
await adapter._handle_message(message)
adapter._auto_create_thread.assert_not_awaited()
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "follow-up from voice text chat"
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter, monkeypatch):
"""Threads under a voice-linked channel should still require @mention."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter._voice_text_channels[111] = 789
message = make_message(
channel=FakeThread(channel_id=790, parent=FakeTextChannel(channel_id=789)),
content="thread reply without mention",
)
await adapter._handle_message(message)
adapter.handle_message.assert_not_awaited()

View File

@@ -124,7 +124,7 @@ class TestSendWithReplyToMode:
@pytest.mark.asyncio
async def test_off_mode_no_reply_reference(self):
adapter, channel, ref_msg = _make_discord_adapter("off")
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
@@ -137,7 +137,7 @@ class TestSendWithReplyToMode:
@pytest.mark.asyncio
async def test_first_mode_only_first_chunk_references(self):
adapter, channel, ref_msg = _make_discord_adapter("first")
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
@@ -152,7 +152,7 @@ class TestSendWithReplyToMode:
@pytest.mark.asyncio
async def test_all_mode_all_chunks_reference(self):
adapter, channel, ref_msg = _make_discord_adapter("all")
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
@@ -165,7 +165,7 @@ class TestSendWithReplyToMode:
@pytest.mark.asyncio
async def test_no_reply_to_param_no_reference(self):
adapter, channel, ref_msg = _make_discord_adapter("all")
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
await adapter.send("12345", "test content", reply_to=None)
@@ -176,7 +176,7 @@ class TestSendWithReplyToMode:
@pytest.mark.asyncio
async def test_single_chunk_respects_first_mode(self):
adapter, channel, ref_msg = _make_discord_adapter("first")
adapter.truncate_message = lambda content, max_len: ["single chunk"]
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
await adapter.send("12345", "test", reply_to="999")
@@ -187,7 +187,7 @@ class TestSendWithReplyToMode:
@pytest.mark.asyncio
async def test_single_chunk_off_mode(self):
adapter, channel, ref_msg = _make_discord_adapter("off")
adapter.truncate_message = lambda content, max_len: ["single chunk"]
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
await adapter.send("12345", "test", reply_to="999")
@@ -200,7 +200,7 @@ class TestSendWithReplyToMode:
async def test_invalid_mode_falls_back_to_first_behavior(self):
"""Invalid mode behaves like 'first' — only first chunk gets reference."""
adapter, channel, ref_msg = _make_discord_adapter("banana")
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
await adapter.send("12345", "test", reply_to="999")

View File

@@ -189,14 +189,14 @@ class TestPlatformDefaults:
"""Slack, Mattermost, Matrix default to 'new' tool progress."""
from gateway.display_config import resolve_display_setting
for plat in ("slack", "mattermost", "matrix", "feishu"):
for plat in ("slack", "mattermost", "matrix", "feishu", "whatsapp"):
assert resolve_display_setting({}, plat, "tool_progress") == "new", plat
def test_low_tier_platforms(self):
"""Signal, WhatsApp, etc. default to 'off' tool progress."""
"""Signal, BlueBubbles, etc. default to 'off' tool progress."""
from gateway.display_config import resolve_display_setting
for plat in ("signal", "whatsapp", "bluebubbles", "weixin", "wecom", "dingtalk"):
for plat in ("signal", "bluebubbles", "weixin", "wecom", "dingtalk"):
assert resolve_display_setting({}, plat, "tool_progress") == "off", plat
def test_minimal_tier_platforms(self):

View File

@@ -0,0 +1,438 @@
"""Tests for gateway.platforms.feishu — Feishu scan-to-create registration."""
import json
from unittest.mock import patch, MagicMock
import pytest
def _mock_urlopen(response_data, status=200):
"""Create a mock for urllib.request.urlopen that returns JSON response_data."""
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(response_data).encode("utf-8")
mock_response.status = status
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
return mock_response
class TestPostRegistration:
"""Tests for the low-level HTTP helper."""
@patch("gateway.platforms.feishu.urlopen")
def test_post_registration_returns_parsed_json(self, mock_urlopen_fn):
from gateway.platforms.feishu import _post_registration
mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]})
result = _post_registration("https://accounts.feishu.cn", {"action": "init"})
assert result["nonce"] == "abc"
assert "client_secret" in result["supported_auth_methods"]
@patch("gateway.platforms.feishu.urlopen")
def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn):
from gateway.platforms.feishu import _post_registration
mock_urlopen_fn.return_value = _mock_urlopen({})
_post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"})
call_args = mock_urlopen_fn.call_args
request = call_args[0][0]
body = request.data.decode("utf-8")
assert "action=init" in body
assert "key=val" in body
assert request.get_header("Content-type") == "application/x-www-form-urlencoded"
class TestInitRegistration:
"""Tests for the init step."""
@patch("gateway.platforms.feishu.urlopen")
def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn):
from gateway.platforms.feishu import _init_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"nonce": "abc",
"supported_auth_methods": ["client_secret"],
})
_init_registration("feishu")
@patch("gateway.platforms.feishu.urlopen")
def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn):
from gateway.platforms.feishu import _init_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"nonce": "abc",
"supported_auth_methods": ["other_method"],
})
with pytest.raises(RuntimeError, match="client_secret"):
_init_registration("feishu")
@patch("gateway.platforms.feishu.urlopen")
def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn):
from gateway.platforms.feishu import _init_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"nonce": "abc",
"supported_auth_methods": ["client_secret"],
})
_init_registration("lark")
call_args = mock_urlopen_fn.call_args
request = call_args[0][0]
assert "larksuite.com" in request.full_url
class TestBeginRegistration:
"""Tests for the begin step."""
@patch("gateway.platforms.feishu.urlopen")
def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn):
from gateway.platforms.feishu import _begin_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"device_code": "dc_123",
"verification_uri_complete": "https://accounts.feishu.cn/qr/abc",
"user_code": "ABCD-1234",
"interval": 5,
"expire_in": 600,
})
result = _begin_registration("feishu")
assert result["device_code"] == "dc_123"
assert "qr_url" in result
assert "accounts.feishu.cn" in result["qr_url"]
assert result["user_code"] == "ABCD-1234"
assert result["interval"] == 5
assert result["expire_in"] == 600
@patch("gateway.platforms.feishu.urlopen")
def test_begin_sends_correct_archetype(self, mock_urlopen_fn):
from gateway.platforms.feishu import _begin_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"device_code": "dc_123",
"verification_uri_complete": "https://example.com/qr",
"user_code": "X",
"interval": 5,
"expire_in": 600,
})
_begin_registration("feishu")
request = mock_urlopen_fn.call_args[0][0]
body = request.data.decode("utf-8")
assert "archetype=PersonalAgent" in body
assert "auth_method=client_secret" in body
class TestPollRegistration:
"""Tests for the poll step."""
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
mock_time.time.side_effect = [0, 1]
mock_time.sleep = MagicMock()
mock_urlopen_fn.return_value = _mock_urlopen({
"client_id": "cli_app123",
"client_secret": "secret456",
"user_info": {"open_id": "ou_owner", "tenant_brand": "feishu"},
})
result = _poll_registration(
device_code="dc_123", interval=1, expire_in=60, domain="feishu"
)
assert result is not None
assert result["app_id"] == "cli_app123"
assert result["app_secret"] == "secret456"
assert result["domain"] == "feishu"
assert result["open_id"] == "ou_owner"
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
mock_time.time.side_effect = [0, 1, 2]
mock_time.sleep = MagicMock()
pending_resp = _mock_urlopen({
"error": "authorization_pending",
"user_info": {"tenant_brand": "lark"},
})
success_resp = _mock_urlopen({
"client_id": "cli_lark",
"client_secret": "secret_lark",
"user_info": {"open_id": "ou_lark", "tenant_brand": "lark"},
})
mock_urlopen_fn.side_effect = [pending_resp, success_resp]
result = _poll_registration(
device_code="dc_123", interval=0, expire_in=60, domain="feishu"
)
assert result is not None
assert result["domain"] == "lark"
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time):
"""Credentials and lark tenant_brand in one response must not be discarded."""
from gateway.platforms.feishu import _poll_registration
mock_time.time.side_effect = [0, 1]
mock_time.sleep = MagicMock()
mock_urlopen_fn.return_value = _mock_urlopen({
"client_id": "cli_lark_direct",
"client_secret": "secret_lark_direct",
"user_info": {"open_id": "ou_lark_direct", "tenant_brand": "lark"},
})
result = _poll_registration(
device_code="dc_123", interval=1, expire_in=60, domain="feishu"
)
assert result is not None
assert result["app_id"] == "cli_lark_direct"
assert result["domain"] == "lark"
assert result["open_id"] == "ou_lark_direct"
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
mock_time.time.side_effect = [0, 1]
mock_time.sleep = MagicMock()
mock_urlopen_fn.return_value = _mock_urlopen({
"error": "access_denied",
})
result = _poll_registration(
device_code="dc_123", interval=1, expire_in=60, domain="feishu"
)
assert result is None
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
mock_time.time.side_effect = [0, 999]
mock_time.sleep = MagicMock()
mock_urlopen_fn.return_value = _mock_urlopen({
"error": "authorization_pending",
})
result = _poll_registration(
device_code="dc_123", interval=1, expire_in=1, domain="feishu"
)
assert result is None
class TestRenderQr:
"""Tests for QR code terminal rendering."""
@patch("gateway.platforms.feishu._qrcode_mod", create=True)
def test_render_qr_returns_true_on_success(self, mock_qrcode_mod):
from gateway.platforms.feishu import _render_qr
mock_qr = MagicMock()
mock_qrcode_mod.QRCode.return_value = mock_qr
assert _render_qr("https://example.com/qr") is True
mock_qr.add_data.assert_called_once_with("https://example.com/qr")
mock_qr.make.assert_called_once_with(fit=True)
mock_qr.print_ascii.assert_called_once()
def test_render_qr_returns_false_when_qrcode_missing(self):
from gateway.platforms.feishu import _render_qr
with patch("gateway.platforms.feishu._qrcode_mod", None):
assert _render_qr("https://example.com/qr") is False
class TestProbeBot:
"""Tests for bot connectivity verification."""
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
def test_probe_returns_bot_info_on_success(self):
from gateway.platforms.feishu import probe_bot
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"}
result = probe_bot("cli_app", "secret", "feishu")
assert result is not None
assert result["bot_name"] == "TestBot"
assert result["bot_open_id"] == "ou_bot123"
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
def test_probe_returns_none_on_failure(self):
from gateway.platforms.feishu import probe_bot
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
mock_sdk.return_value = None
result = probe_bot("bad_id", "bad_secret", "feishu")
assert result is None
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
@patch("gateway.platforms.feishu.urlopen")
def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn):
"""Without lark_oapi, probe falls back to raw HTTP."""
from gateway.platforms.feishu import probe_bot
token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"})
bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}})
mock_urlopen_fn.side_effect = [token_resp, bot_resp]
result = probe_bot("cli_app", "secret", "feishu")
assert result is not None
assert result["bot_name"] == "HttpBot"
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
@patch("gateway.platforms.feishu.urlopen")
def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn):
from gateway.platforms.feishu import probe_bot
from urllib.error import URLError
mock_urlopen_fn.side_effect = URLError("connection refused")
result = probe_bot("cli_app", "secret", "feishu")
assert result is None
class TestQrRegister:
"""Tests for the public qr_register entry point."""
@patch("gateway.platforms.feishu.probe_bot")
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._poll_registration")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_success_flow(
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
):
from gateway.platforms.feishu import qr_register
mock_begin.return_value = {
"device_code": "dc_123",
"qr_url": "https://example.com/qr",
"user_code": "ABCD",
"interval": 1,
"expire_in": 60,
}
mock_poll.return_value = {
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
"open_id": "ou_owner",
}
mock_probe.return_value = {"bot_name": "MyBot", "bot_open_id": "ou_bot"}
result = qr_register()
assert result is not None
assert result["app_id"] == "cli_app"
assert result["app_secret"] == "secret"
assert result["bot_name"] == "MyBot"
mock_init.assert_called_once()
mock_render.assert_called_once()
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_returns_none_on_init_failure(self, mock_init):
from gateway.platforms.feishu import qr_register
mock_init.side_effect = RuntimeError("not supported")
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._poll_registration")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_returns_none_on_poll_failure(
self, mock_init, mock_begin, mock_poll, mock_render
):
from gateway.platforms.feishu import qr_register
mock_begin.return_value = {
"device_code": "dc_123",
"qr_url": "https://example.com/qr",
"user_code": "ABCD",
"interval": 1,
"expire_in": 60,
}
mock_poll.return_value = None
result = qr_register()
assert result is None
# -- Contract: expected errors → None, unexpected errors → propagate --
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_returns_none_on_network_error(self, mock_init):
"""URLError (network down) is an expected failure → None."""
from gateway.platforms.feishu import qr_register
from urllib.error import URLError
mock_init.side_effect = URLError("DNS resolution failed")
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_returns_none_on_json_error(self, mock_init):
"""Malformed server response is an expected failure → None."""
from gateway.platforms.feishu import qr_register
mock_init.side_effect = json.JSONDecodeError("bad json", "", 0)
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_propagates_unexpected_errors(self, mock_init):
"""Bugs (e.g. AttributeError) must not be swallowed — they propagate."""
from gateway.platforms.feishu import qr_register
mock_init.side_effect = AttributeError("some internal bug")
with pytest.raises(AttributeError, match="some internal bug"):
qr_register()
# -- Negative paths: partial/malformed server responses --
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_returns_none_when_begin_missing_device_code(
self, mock_init, mock_begin, mock_render
):
"""Server returns begin response without device_code → RuntimeError → None."""
from gateway.platforms.feishu import qr_register
mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code")
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu.probe_bot")
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._poll_registration")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
def test_qr_register_succeeds_even_when_probe_fails(
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
):
"""Registration succeeds but probe fails → result with bot_name=None."""
from gateway.platforms.feishu import qr_register
mock_begin.return_value = {
"device_code": "dc_123",
"qr_url": "https://example.com/qr",
"user_code": "ABCD",
"interval": 1,
"expire_in": 60,
}
mock_poll.return_value = {
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
"open_id": "ou_owner",
}
mock_probe.return_value = None # probe failed
result = qr_register()
assert result is not None
assert result["app_id"] == "cli_app"
assert result["bot_name"] is None
assert result["bot_open_id"] is None

View File

@@ -48,6 +48,7 @@ def _make_event(
room_id="!room1:example.org",
formatted_body=None,
thread_id=None,
mention_user_ids=None,
):
"""Create a fake room message event.
@@ -60,6 +61,9 @@ def _make_event(
content["formatted_body"] = formatted_body
content["format"] = "org.matrix.custom.html"
if mention_user_ids is not None:
content["m.mentions"] = {"user_ids": mention_user_ids}
relates_to = {}
if thread_id:
relates_to["rel_type"] = "m.thread"
@@ -108,6 +112,44 @@ class TestIsBotMentioned:
# "hermesbot" should not match word-boundary check for "hermes"
assert not self.adapter._is_bot_mentioned("hermesbot is here")
# m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions
# Ported from openclaw/openclaw#64796
def test_m_mentions_user_ids_authoritative(self):
"""m.mentions.user_ids alone is sufficient — no body text needed."""
assert self.adapter._is_bot_mentioned(
"please reply", # no @hermes anywhere in body
mention_user_ids=["@hermes:example.org"],
)
def test_m_mentions_user_ids_with_body_mention(self):
"""Both m.mentions and body mention — should still be True."""
assert self.adapter._is_bot_mentioned(
"hey @hermes:example.org help",
mention_user_ids=["@hermes:example.org"],
)
def test_m_mentions_user_ids_other_user_only(self):
"""m.mentions with a different user — bot is NOT mentioned."""
assert not self.adapter._is_bot_mentioned(
"hello",
mention_user_ids=["@alice:example.org"],
)
def test_m_mentions_user_ids_empty_list(self):
"""Empty user_ids list — falls through to text detection."""
assert not self.adapter._is_bot_mentioned(
"hello everyone",
mention_user_ids=[],
)
def test_m_mentions_user_ids_none(self):
"""None mention_user_ids — falls through to text detection."""
assert not self.adapter._is_bot_mentioned(
"hello everyone",
mention_user_ids=None,
)
class TestStripMention:
def setup_method(self):
@@ -176,6 +218,44 @@ async def test_require_mention_html_pill(monkeypatch):
adapter.handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_require_mention_m_mentions_user_ids(monkeypatch):
"""m.mentions.user_ids is authoritative per MSC3952 — no body mention needed.
Ported from openclaw/openclaw#64796.
"""
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
# Body has NO mention, but m.mentions.user_ids includes the bot.
event = _make_event(
"please reply",
mention_user_ids=["@hermes:example.org"],
)
await adapter._on_room_message(event)
adapter.handle_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_require_mention_m_mentions_other_user_ignored(monkeypatch):
"""m.mentions.user_ids mentioning another user should NOT activate the bot."""
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
adapter = _make_adapter()
event = _make_event(
"hey alice check this",
mention_user_ids=["@alice:example.org"],
)
await adapter._on_room_message(event)
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_require_mention_dm_always_responds(monkeypatch):
"""DMs always respond regardless of mention setting."""

View File

@@ -9,6 +9,8 @@ from gateway.platforms.base import (
MessageEvent,
MessageType,
safe_url_for_log,
utf16_len,
_prefix_within_utf16_limit,
)
@@ -448,3 +450,135 @@ class TestGetHumanDelay:
with patch.dict(os.environ, env):
delay = BasePlatformAdapter._get_human_delay()
assert 0.1 <= delay <= 0.2
# ---------------------------------------------------------------------------
# utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn
# ---------------------------------------------------------------------------
# Ported from nearai/ironclaw#2304 — Telegram counts message length in UTF-16
# code units, not Unicode code-points. Astral-plane characters (emoji, CJK
# Extension B) are surrogate pairs: 1 Python char but 2 UTF-16 units.
class TestUtf16Len:
"""Verify the UTF-16 length helper."""
def test_ascii(self):
assert utf16_len("hello") == 5
def test_bmp_cjk(self):
# CJK ideographs in the BMP are 1 code unit each
assert utf16_len("你好") == 2
def test_emoji_surrogate_pair(self):
# 😀 (U+1F600) is outside BMP → 2 UTF-16 code units
assert utf16_len("😀") == 2
def test_mixed(self):
# "hi😀" = 2 + 2 = 4 UTF-16 units
assert utf16_len("hi😀") == 4
def test_musical_symbol(self):
# 𝄞 (U+1D11E) — Musical Symbol G Clef, surrogate pair
assert utf16_len("𝄞") == 2
def test_empty(self):
assert utf16_len("") == 0
class TestPrefixWithinUtf16Limit:
"""Verify UTF-16-aware prefix truncation."""
def test_fits_entirely(self):
assert _prefix_within_utf16_limit("hello", 10) == "hello"
def test_ascii_truncation(self):
result = _prefix_within_utf16_limit("hello world", 5)
assert result == "hello"
assert utf16_len(result) <= 5
def test_does_not_split_surrogate_pair(self):
# "a😀b" = 1 + 2 + 1 = 4 UTF-16 units; limit 2 should give "a"
result = _prefix_within_utf16_limit("a😀b", 2)
assert result == "a"
assert utf16_len(result) <= 2
def test_emoji_at_limit(self):
# "😀" = 2 UTF-16 units; limit 2 should include it
result = _prefix_within_utf16_limit("😀x", 2)
assert result == "😀"
def test_all_emoji(self):
msg = "😀" * 10 # 20 UTF-16 units
result = _prefix_within_utf16_limit(msg, 6)
assert result == "😀😀😀"
assert utf16_len(result) == 6
def test_empty(self):
assert _prefix_within_utf16_limit("", 5) == ""
class TestTruncateMessageUtf16:
"""Verify truncate_message respects UTF-16 lengths when len_fn=utf16_len."""
def test_short_emoji_message_no_split(self):
"""A short message under the UTF-16 limit should not be split."""
msg = "Hello 😀 world"
chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len)
assert len(chunks) == 1
assert chunks[0] == msg
def test_emoji_near_limit_triggers_split(self):
"""A message at 4096 codepoints but >4096 UTF-16 units must split."""
# 2049 emoji = 2049 codepoints but 4098 UTF-16 units → exceeds 4096
msg = "😀" * 2049
assert len(msg) == 2049 # Python len sees 2049 chars
assert utf16_len(msg) == 4098 # but it's 4098 UTF-16 units
# Without UTF-16 awareness, this would NOT split (2049 < 4096)
chunks_naive = BasePlatformAdapter.truncate_message(msg, 4096)
assert len(chunks_naive) == 1, "Without len_fn, no split expected"
# With UTF-16 awareness, it MUST split
chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len)
assert len(chunks) > 1, "With utf16_len, message should be split"
# Each chunk must fit within the UTF-16 limit
for i, chunk in enumerate(chunks):
assert utf16_len(chunk) <= 4096, (
f"Chunk {i} exceeds 4096 UTF-16 units: {utf16_len(chunk)}"
)
def test_each_utf16_chunk_within_limit(self):
"""All chunks produced with utf16_len must fit the limit."""
# Mix of BMP and astral-plane characters
msg = ("Hello 😀 world 🎵 test 𝄞 " * 200).strip()
max_len = 200
chunks = BasePlatformAdapter.truncate_message(msg, max_len, len_fn=utf16_len)
for i, chunk in enumerate(chunks):
u16_len = utf16_len(chunk)
assert u16_len <= max_len + 20, (
f"Chunk {i} UTF-16 length {u16_len} exceeds {max_len}"
)
def test_all_content_preserved(self):
"""Splitting with utf16_len must not lose content."""
words = ["emoji😀", "music🎵", "cjk你好", "plain"] * 100
msg = " ".join(words)
chunks = BasePlatformAdapter.truncate_message(msg, 200, len_fn=utf16_len)
reassembled = " ".join(chunks)
for word in words:
assert word in reassembled, f"Word '{word}' lost during UTF-16 split"
def test_code_blocks_preserved_with_utf16(self):
"""Code block fence handling should work with utf16_len too."""
msg = "Before\n```python\n" + "x = '😀'\n" * 200 + "```\nAfter"
chunks = BasePlatformAdapter.truncate_message(msg, 300, len_fn=utf16_len)
assert len(chunks) > 1
# Each chunk should have balanced fences
for i, chunk in enumerate(chunks):
fence_count = chunk.count("```")
assert fence_count % 2 == 0, (
f"Chunk {i} has unbalanced fences ({fence_count})"
)

View File

@@ -0,0 +1,215 @@
"""Tests for /restart notification — the gateway notifies the requester on comeback."""
import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import build_session_key
from tests.gateway.restart_test_helpers import (
make_restart_runner,
make_restart_source,
)
# ── _handle_restart_command writes .restart_notify.json ──────────────────
@pytest.mark.asyncio
async def test_restart_command_writes_notify_file(tmp_path, monkeypatch):
"""When /restart fires, the requester's routing info is persisted to disk."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
source = make_restart_source(chat_id="42")
event = MessageEvent(
text="/restart",
message_type=MessageType.TEXT,
source=source,
message_id="m1",
)
result = await runner._handle_restart_command(event)
assert "Restarting" in result
notify_path = tmp_path / ".restart_notify.json"
assert notify_path.exists()
data = json.loads(notify_path.read_text())
assert data["platform"] == "telegram"
assert data["chat_id"] == "42"
assert "thread_id" not in data # no thread → omitted
@pytest.mark.asyncio
async def test_restart_command_uses_service_restart_under_systemd(tmp_path, monkeypatch):
"""Under systemd (INVOCATION_ID set), /restart uses via_service=True."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setenv("INVOCATION_ID", "abc123")
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
source = make_restart_source(chat_id="42")
event = MessageEvent(
text="/restart",
message_type=MessageType.TEXT,
source=source,
message_id="m1",
)
await runner._handle_restart_command(event)
runner.request_restart.assert_called_once_with(detached=False, via_service=True)
@pytest.mark.asyncio
async def test_restart_command_uses_detached_without_systemd(tmp_path, monkeypatch):
"""Without systemd, /restart uses the detached subprocess approach."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.delenv("INVOCATION_ID", raising=False)
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
source = make_restart_source(chat_id="42")
event = MessageEvent(
text="/restart",
message_type=MessageType.TEXT,
source=source,
message_id="m1",
)
await runner._handle_restart_command(event)
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
@pytest.mark.asyncio
async def test_restart_command_preserves_thread_id(tmp_path, monkeypatch):
"""Thread ID is saved when the requester is in a threaded chat."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
source = make_restart_source(chat_id="99")
source.thread_id = "topic_7"
event = MessageEvent(
text="/restart",
message_type=MessageType.TEXT,
source=source,
message_id="m2",
)
await runner._handle_restart_command(event)
data = json.loads((tmp_path / ".restart_notify.json").read_text())
assert data["thread_id"] == "topic_7"
# ── _send_restart_notification ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_send_restart_notification_delivers_and_cleans_up(tmp_path, monkeypatch):
"""On startup, the notification is sent and the file is removed."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
notify_path = tmp_path / ".restart_notify.json"
notify_path.write_text(json.dumps({
"platform": "telegram",
"chat_id": "42",
}))
runner, adapter = make_restart_runner()
adapter.send = AsyncMock()
await runner._send_restart_notification()
adapter.send.assert_called_once()
call_args = adapter.send.call_args
assert call_args[0][0] == "42" # chat_id
assert "restarted" in call_args[0][1].lower()
assert call_args[1].get("metadata") is None # no thread
assert not notify_path.exists()
@pytest.mark.asyncio
async def test_send_restart_notification_with_thread(tmp_path, monkeypatch):
"""Thread ID is passed as metadata so the message lands in the right topic."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
notify_path = tmp_path / ".restart_notify.json"
notify_path.write_text(json.dumps({
"platform": "telegram",
"chat_id": "99",
"thread_id": "topic_7",
}))
runner, adapter = make_restart_runner()
adapter.send = AsyncMock()
await runner._send_restart_notification()
call_args = adapter.send.call_args
assert call_args[1]["metadata"] == {"thread_id": "topic_7"}
assert not notify_path.exists()
@pytest.mark.asyncio
async def test_send_restart_notification_noop_when_no_file(tmp_path, monkeypatch):
"""Nothing happens if there's no pending restart notification."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
runner, adapter = make_restart_runner()
adapter.send = AsyncMock()
await runner._send_restart_notification()
adapter.send.assert_not_called()
@pytest.mark.asyncio
async def test_send_restart_notification_skips_when_adapter_missing(tmp_path, monkeypatch):
"""If the requester's platform isn't connected, clean up without crashing."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
notify_path = tmp_path / ".restart_notify.json"
notify_path.write_text(json.dumps({
"platform": "discord", # runner only has telegram adapter
"chat_id": "42",
}))
runner, _adapter = make_restart_runner()
await runner._send_restart_notification()
# File cleaned up even though we couldn't send
assert not notify_path.exists()
@pytest.mark.asyncio
async def test_send_restart_notification_cleans_up_on_send_failure(
tmp_path, monkeypatch
):
"""If the adapter.send() raises, the file is still cleaned up."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
notify_path = tmp_path / ".restart_notify.json"
notify_path.write_text(json.dumps({
"platform": "telegram",
"chat_id": "42",
}))
runner, adapter = make_restart_runner()
adapter.send = AsyncMock(side_effect=RuntimeError("network down"))
await runner._send_restart_notification()
assert not notify_path.exists() # cleaned up despite error

View File

@@ -396,6 +396,27 @@ class QueuedCommentaryAgent:
}
class VerboseAgent:
"""Agent that emits a tool call with args whose JSON exceeds 200 chars."""
LONG_CODE = "x" * 300
def __init__(self, **kwargs):
self.tool_progress_callback = kwargs.get("tool_progress_callback")
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None):
self.tool_progress_callback(
"tool.started", "execute_code", None,
{"code": self.LONG_CODE},
)
time.sleep(0.35)
return {
"final_response": "done",
"messages": [],
"api_calls": 1,
}
async def _run_with_agent(
monkeypatch,
tmp_path,
@@ -575,3 +596,45 @@ async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monke
assert result["final_response"] == "final response 2"
assert "I'll inspect the repo first." in sent_texts
assert "final response 1" in sent_texts
@pytest.mark.asyncio
async def test_verbose_mode_does_not_truncate_args_by_default(monkeypatch, tmp_path):
"""Verbose mode with default tool_preview_length (0) should NOT truncate args.
Previously, verbose mode capped args at 200 chars when tool_preview_length
was 0 (default). The user explicitly opted into verbose — show full detail.
"""
adapter, result = await _run_with_agent(
monkeypatch,
tmp_path,
VerboseAgent,
session_id="sess-verbose-no-truncate",
config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 0}},
)
assert result["final_response"] == "done"
# The full 300-char 'x' string should be present, not truncated to 200
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
assert VerboseAgent.LONG_CODE in all_content
@pytest.mark.asyncio
async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, tmp_path):
"""When tool_preview_length is set to a positive value, verbose truncates to that."""
adapter, result = await _run_with_agent(
monkeypatch,
tmp_path,
VerboseAgent,
session_id="sess-verbose-explicit-cap",
config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 50}},
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
# Should be truncated — full 300-char string NOT present
assert VerboseAgent.LONG_CODE not in all_content
# But should still contain the truncated portion with "..."
assert "..." in all_content

View File

@@ -552,6 +552,45 @@ class TestLoadTranscriptPreferLongerSource:
assert result[0]["content"] == "db-q"
class TestSessionStoreSwitchSession:
"""Regression coverage for gateway /resume session switching semantics."""
def test_switch_session_reopens_target_session_in_db(self, tmp_path):
from hermes_state import SessionDB
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
store = SessionStore(sessions_dir=tmp_path / "sessions", config=config)
db = SessionDB(db_path=tmp_path / "state.db")
store._db = db
store._loaded = True
source = SessionSource(
platform=Platform.FEISHU,
chat_id="chat-1",
chat_type="dm",
user_id="user-1",
user_name="tester",
)
current_entry = store.get_or_create_session(source)
current_session_id = current_entry.session_id
target_session_id = "old_session_abc"
db.create_session(target_session_id, source="feishu", user_id="user-1")
db.end_session(target_session_id, end_reason="user_exit")
assert db.get_session(target_session_id)["ended_at"] is not None
switched = store.switch_session(current_entry.session_key, target_session_id)
assert switched is not None
assert switched.session_id == target_session_id
assert db.get_session(current_session_id)["end_reason"] == "session_switch"
resumed = db.get_session(target_session_id)
assert resumed["ended_at"] is None
assert resumed["end_reason"] is None
db.close()
class TestWhatsAppDMSessionKeyConsistency:
"""Regression: all session-key construction must go through build_session_key
so DMs are isolated by chat_id across platforms."""

View File

@@ -60,7 +60,8 @@ def _make_runner():
def _make_event(text="hello", chat_id="12345"):
source = SessionSource(
platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"
platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm",
user_id="u1",
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
@@ -192,7 +193,8 @@ async def test_command_messages_do_not_leave_sentinel():
_handle_message. They must NOT leave a sentinel behind."""
runner = _make_runner()
source = SessionSource(
platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"
platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm",
user_id="u1",
)
event = MessageEvent(
text="/help", message_type=MessageType.TEXT, source=source
@@ -240,9 +242,7 @@ async def test_stop_during_sentinel_force_cleans_session():
stop_event = _make_event(text="/stop")
result = await runner._handle_message(stop_event)
assert result is not None, "/stop during sentinel should return a message"
assert "force-stopped" in result.lower() or "unlocked" in result.lower()
# Sentinel must be cleaned up
assert "stopped" in result.lower()
assert session_key not in runner._running_agents, (
"/stop must remove sentinel so the session is unlocked"
)
@@ -268,7 +268,7 @@ async def test_stop_hard_kills_running_agent():
forever — showing 'writing...' but never producing output."""
runner = _make_runner()
session_key = build_session_key(
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="u1")
)
# Simulate a running (possibly hung) agent
@@ -289,7 +289,7 @@ async def test_stop_hard_kills_running_agent():
# Must return a confirmation
assert result is not None
assert "force-stopped" in result.lower() or "unlocked" in result.lower()
assert "stopped" in result.lower()
# ------------------------------------------------------------------
@@ -301,7 +301,7 @@ async def test_stop_clears_pending_messages():
queued during the run must be discarded."""
runner = _make_runner()
session_key = build_session_key(
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="u1")
)
fake_agent = MagicMock()

View File

@@ -0,0 +1,279 @@
"""Tests for _setup_feishu() in hermes_cli/gateway.py.
Verifies that the interactive setup writes env vars that correctly drive the
Feishu adapter: credentials, connection mode, DM policy, and group policy.
"""
import os
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_setup_feishu(
*,
qr_result=None,
prompt_yes_no_responses=None,
prompt_choice_responses=None,
prompt_responses=None,
existing_env=None,
):
"""Run _setup_feishu() with mocked I/O and return the env vars that were saved.
Returns a dict of {env_var_name: value} for all save_env_value calls.
"""
existing_env = existing_env or {}
prompt_yes_no_responses = list(prompt_yes_no_responses or [True])
# QR path: method(0), dm(0), group(0) — 3 choices (no connection mode)
# Manual path: method(1), domain(0), connection(0), dm(0), group(0) — 5 choices
prompt_choice_responses = list(prompt_choice_responses or [0, 0, 0])
prompt_responses = list(prompt_responses or [""])
saved_env = {}
def mock_save(name, value):
saved_env[name] = value
def mock_get(name):
return existing_env.get(name, "")
with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \
patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \
patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \
patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \
patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \
patch("hermes_cli.gateway.print_info"), \
patch("hermes_cli.gateway.print_success"), \
patch("hermes_cli.gateway.print_warning"), \
patch("hermes_cli.gateway.print_error"), \
patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
patch("gateway.platforms.feishu.qr_register", return_value=qr_result):
from hermes_cli.gateway import _setup_feishu
_setup_feishu()
return saved_env
# ---------------------------------------------------------------------------
# QR scan-to-create path
# ---------------------------------------------------------------------------
class TestSetupFeishuQrPath:
"""Tests for the QR scan-to-create happy path."""
def test_qr_success_saves_core_credentials(self):
env = _run_setup_feishu(
qr_result={
"app_id": "cli_test",
"app_secret": "secret_test",
"domain": "feishu",
"open_id": "ou_owner",
"bot_name": "TestBot",
"bot_open_id": "ou_bot",
},
prompt_yes_no_responses=[True], # Start QR
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
prompt_responses=[""], # home channel: skip
)
assert env["FEISHU_APP_ID"] == "cli_test"
assert env["FEISHU_APP_SECRET"] == "secret_test"
assert env["FEISHU_DOMAIN"] == "feishu"
def test_qr_success_does_not_persist_bot_identity(self):
"""Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted
in env, so it stays fresh if the user renames the bot later."""
env = _run_setup_feishu(
qr_result={
"app_id": "cli_test",
"app_secret": "secret_test",
"domain": "feishu",
"open_id": "ou_owner",
"bot_name": "TestBot",
"bot_open_id": "ou_bot",
},
prompt_yes_no_responses=[True],
prompt_choice_responses=[0, 0, 0],
prompt_responses=[""],
)
assert "FEISHU_BOT_OPEN_ID" not in env
assert "FEISHU_BOT_NAME" not in env
# ---------------------------------------------------------------------------
# Connection mode
# ---------------------------------------------------------------------------
class TestSetupFeishuConnectionMode:
"""Connection mode: QR always websocket, manual path lets user choose."""
def test_qr_path_defaults_to_websocket(self):
env = _run_setup_feishu(
qr_result={
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
"open_id": None, "bot_name": None, "bot_open_id": None,
},
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
prompt_responses=[""],
)
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
def test_manual_path_websocket(self, _mock_probe):
env = _run_setup_feishu(
qr_result=None,
prompt_choice_responses=[1, 0, 0, 0, 0], # method=manual, domain=feishu, connection=ws, dm=pairing, group=open
prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel
)
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
def test_manual_path_webhook(self, _mock_probe):
env = _run_setup_feishu(
qr_result=None,
prompt_choice_responses=[1, 0, 1, 0, 0], # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open
prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel
)
assert env["FEISHU_CONNECTION_MODE"] == "webhook"
# ---------------------------------------------------------------------------
# DM security policy
# ---------------------------------------------------------------------------
class TestSetupFeishuDmPolicy:
"""DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag."""
def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None):
return _run_setup_feishu(
qr_result={
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
"open_id": "ou_owner", "bot_name": None, "bot_open_id": None,
},
prompt_yes_no_responses=[True],
prompt_choice_responses=[0, dm_choice_idx, 0], # method=QR, dm=<choice>, group=open
prompt_responses=prompt_responses or [""],
)
def test_pairing_sets_feishu_allow_all_false(self):
env = self._run_with_dm_choice(0)
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
assert env["FEISHU_ALLOWED_USERS"] == ""
assert "GATEWAY_ALLOW_ALL_USERS" not in env
def test_allow_all_sets_feishu_allow_all_true(self):
env = self._run_with_dm_choice(1)
assert env["FEISHU_ALLOW_ALL_USERS"] == "true"
assert env["FEISHU_ALLOWED_USERS"] == ""
assert "GATEWAY_ALLOW_ALL_USERS" not in env
def test_allowlist_sets_feishu_allow_all_false_with_list(self):
env = self._run_with_dm_choice(2, prompt_responses=["ou_user1,ou_user2", ""])
assert env["FEISHU_ALLOW_ALL_USERS"] == "false"
assert env["FEISHU_ALLOWED_USERS"] == "ou_user1,ou_user2"
assert "GATEWAY_ALLOW_ALL_USERS" not in env
def test_allowlist_prepopulates_with_scan_owner_open_id(self):
"""When open_id is available from QR scan, it should be the default allowlist value."""
# We return the owner's open_id from prompt (+ empty home channel).
env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""])
assert env["FEISHU_ALLOWED_USERS"] == "ou_owner"
# ---------------------------------------------------------------------------
# Group policy
# ---------------------------------------------------------------------------
class TestSetupFeishuGroupPolicy:
def test_open_with_mention(self):
env = _run_setup_feishu(
qr_result={
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
"open_id": None, "bot_name": None, "bot_open_id": None,
},
prompt_yes_no_responses=[True],
prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open
prompt_responses=[""],
)
assert env["FEISHU_GROUP_POLICY"] == "open"
def test_disabled(self):
env = _run_setup_feishu(
qr_result={
"app_id": "cli_test", "app_secret": "s", "domain": "feishu",
"open_id": None, "bot_name": None, "bot_open_id": None,
},
prompt_yes_no_responses=[True],
prompt_choice_responses=[0, 0, 1], # method=QR, dm=pairing, group=disabled
prompt_responses=[""],
)
assert env["FEISHU_GROUP_POLICY"] == "disabled"
# ---------------------------------------------------------------------------
# Adapter integration: env vars → FeishuAdapterSettings
# ---------------------------------------------------------------------------
class TestSetupFeishuAdapterIntegration:
"""Verify that env vars written by _setup_feishu() produce a valid adapter config.
This bridges the gap between 'setup wrote the right env vars' and
'the adapter will actually initialize correctly from those vars'.
"""
def _make_env_from_setup(self, dm_idx=0, group_idx=0):
"""Run _setup_feishu via QR path and return the env vars it would write."""
return _run_setup_feishu(
qr_result={
"app_id": "cli_test_app",
"app_secret": "test_secret_value",
"domain": "feishu",
"open_id": "ou_owner",
"bot_name": "IntegrationBot",
"bot_open_id": "ou_bot_integration",
},
prompt_yes_no_responses=[True],
prompt_choice_responses=[0, dm_idx, group_idx], # method=QR, dm, group
prompt_responses=[""],
)
@patch.dict(os.environ, {}, clear=True)
def test_qr_env_produces_valid_adapter_settings(self):
"""QR setup → adapter initializes with websocket mode."""
env = self._make_env_from_setup()
with patch.dict(os.environ, env, clear=True):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
assert adapter._app_id == "cli_test_app"
assert adapter._app_secret == "test_secret_value"
assert adapter._domain_name == "feishu"
assert adapter._connection_mode == "websocket"
@patch.dict(os.environ, {}, clear=True)
def test_open_dm_env_sets_correct_adapter_state(self):
"""Setup with 'allow all DMs' → adapter sees allow-all flag."""
env = self._make_env_from_setup(dm_idx=1)
with patch.dict(os.environ, env, clear=True):
from gateway.platforms.feishu import FeishuAdapter
from gateway.config import PlatformConfig
# Verify adapter initializes without error and env var is correct.
FeishuAdapter(PlatformConfig())
assert os.getenv("FEISHU_ALLOW_ALL_USERS") == "true"
@patch.dict(os.environ, {}, clear=True)
def test_group_open_env_sets_adapter_group_policy(self):
"""Setup with 'open groups' → adapter group_policy is 'open'."""
env = self._make_env_from_setup(group_idx=0)
with patch.dict(os.environ, env, clear=True):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
assert adapter._group_policy == "open"

View File

@@ -209,6 +209,33 @@ class TestScopedLocks:
assert payload["pid"] == os.getpid()
assert payload["metadata"]["platform"] == "telegram"
def test_acquire_scoped_lock_recovers_empty_lock_file(self, tmp_path, monkeypatch):
"""Empty lock file (0 bytes) left by a crashed process should be treated as stale."""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
lock_path = tmp_path / "locks" / "slack-app-token-2bb80d537b1da3e3.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text("") # simulate crash between O_CREAT and json.dump
acquired, existing = status.acquire_scoped_lock("slack-app-token", "secret", metadata={"platform": "slack"})
assert acquired is True
payload = json.loads(lock_path.read_text())
assert payload["pid"] == os.getpid()
assert payload["metadata"]["platform"] == "slack"
def test_acquire_scoped_lock_recovers_corrupt_lock_file(self, tmp_path, monkeypatch):
"""Lock file with invalid JSON should be treated as stale."""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
lock_path = tmp_path / "locks" / "slack-app-token-2bb80d537b1da3e3.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text("{truncated") # simulate partial write
acquired, existing = status.acquire_scoped_lock("slack-app-token", "secret", metadata={"platform": "slack"})
assert acquired is True
payload = json.loads(lock_path.read_text())
assert payload["pid"] == os.getpid()
def test_release_scoped_lock_only_removes_current_owner(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))

View File

@@ -29,7 +29,7 @@ def _make_runner():
@pytest.mark.asyncio
async def test_handle_message_does_not_priority_interrupt_photo_followup():
runner = _make_runner()
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="u1")
session_key = build_session_key(source)
running_agent = MagicMock()
runner._running_agents[session_key] = running_agent

View File

@@ -121,7 +121,7 @@ class TestSendWithReplyToMode:
adapter = adapter_factory(reply_to_mode="off")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
@@ -133,7 +133,7 @@ class TestSendWithReplyToMode:
adapter = adapter_factory(reply_to_mode="first")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
@@ -148,7 +148,7 @@ class TestSendWithReplyToMode:
adapter = adapter_factory(reply_to_mode="all")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
@@ -162,7 +162,7 @@ class TestSendWithReplyToMode:
adapter = adapter_factory(reply_to_mode="all")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
await adapter.send("12345", "test content", reply_to=None)
@@ -175,7 +175,7 @@ class TestSendWithReplyToMode:
adapter = adapter_factory(reply_to_mode="first")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["single chunk"]
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
await adapter.send("12345", "test", reply_to="999")

View File

@@ -417,6 +417,7 @@ class TestDiscordPlayTtsSkip:
adapter.config = config
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_timeout_tasks = {}
adapter._voice_receivers = {}
adapter._voice_listen_tasks = {}
@@ -702,13 +703,18 @@ class TestVoiceChannelCommands:
mock_adapter.join_voice_channel = AsyncMock(return_value=True)
mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)
mock_adapter._voice_text_channels = {}
mock_adapter._voice_sources = {}
mock_adapter._voice_input_callback = None
event = self._make_discord_event()
event.source.chat_type = "group"
event.source.chat_name = "Hermes Server / #general"
runner.adapters[event.source.platform] = mock_adapter
result = await runner._handle_voice_channel_join(event)
assert "joined" in result.lower()
assert "General" in result
assert runner._voice_mode["123"] == "all"
assert mock_adapter._voice_sources[111]["chat_id"] == "123"
assert mock_adapter._voice_sources[111]["chat_type"] == "group"
@pytest.mark.asyncio
async def test_join_failure(self, runner):
@@ -815,6 +821,7 @@ class TestVoiceChannelCommands:
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
@@ -828,12 +835,45 @@ class TestVoiceChannelCommands:
assert event.source.chat_id == "123"
assert event.source.chat_type == "channel"
@pytest.mark.asyncio
async def test_input_reuses_bound_source_metadata(self, runner):
"""Voice input should share the linked text channel session metadata."""
from gateway.config import Platform
bound_source = SessionSource(
chat_id="123",
chat_name="Hermes Server / #general",
chat_type="group",
user_id="user1",
user_name="user1",
platform=Platform.DISCORD,
)
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {111: bound_source.to_dict()}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
mock_adapter.handle_message = AsyncMock()
runner.adapters[Platform.DISCORD] = mock_adapter
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
mock_adapter.handle_message.assert_called_once()
event = mock_adapter.handle_message.call_args[0][0]
assert event.source.chat_id == "123"
assert event.source.chat_type == "group"
assert event.source.chat_name == "Hermes Server / #general"
assert event.source.user_id == "42"
@pytest.mark.asyncio
async def test_input_posts_transcript_in_text_channel(self, runner):
"""Voice input sends transcript message to text channel."""
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
@@ -892,6 +932,7 @@ class TestDiscordVoiceChannelMethods:
adapter._client = MagicMock()
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_timeout_tasks = {}
adapter._voice_receivers = {}
adapter._voice_listen_tasks = {}
@@ -926,6 +967,7 @@ class TestDiscordVoiceChannelMethods:
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 123
adapter._voice_sources[111] = {"chat_id": "123", "chat_type": "group"}
mock_receiver = MagicMock()
adapter._voice_receivers[111] = mock_receiver
@@ -944,6 +986,7 @@ class TestDiscordVoiceChannelMethods:
mock_timeout.cancel.assert_called_once()
assert 111 not in adapter._voice_clients
assert 111 not in adapter._voice_text_channels
assert 111 not in adapter._voice_sources
assert 111 not in adapter._voice_receivers
@pytest.mark.asyncio
@@ -1670,6 +1713,7 @@ class TestVoiceTimeoutCleansRunnerState:
adapter.config = config
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_timeout_tasks = {}
adapter._voice_receivers = {}
adapter._voice_listen_tasks = {}
@@ -1759,6 +1803,7 @@ class TestPlaybackTimeout:
adapter.config = config
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_timeout_tasks = {}
adapter._voice_receivers = {}
adapter._voice_listen_tasks = {}
@@ -1939,6 +1984,7 @@ class TestVoiceChannelAwareness:
adapter = object.__new__(DiscordAdapter)
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_receivers = {}
adapter._client = MagicMock()
adapter._client.user = SimpleNamespace(id=99999, name="HermesBot")
@@ -2408,6 +2454,7 @@ class TestVoiceTTSPlayback:
adapter.config = config
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_receivers = {}
return adapter
@@ -2587,6 +2634,7 @@ class TestUDPKeepalive:
adapter.config = config
adapter._voice_clients = {}
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter._voice_receivers = {}
adapter._voice_listen_tasks = {}

View File

@@ -0,0 +1,141 @@
"""Tests for gateway weak credential rejection at startup.
Ported from openclaw/openclaw#64586: rejects known-weak placeholder
tokens at gateway startup instead of letting them silently fail
against platform APIs.
"""
import logging
import pytest
from gateway.config import PlatformConfig, Platform, _validate_gateway_config
# ---------------------------------------------------------------------------
# Helper: create a minimal GatewayConfig with one enabled platform
# ---------------------------------------------------------------------------
def _make_gateway_config(platform, token, enabled=True, **extra_kwargs):
"""Create a minimal GatewayConfig-like object for validation testing."""
from gateway.config import GatewayConfig
config = GatewayConfig(platforms={})
pconfig = PlatformConfig(enabled=enabled, token=token, **extra_kwargs)
config.platforms[platform] = pconfig
return config
def _validate_and_return(config):
"""Call _validate_gateway_config and return the config (mutated in place)."""
_validate_gateway_config(config)
return config
# ---------------------------------------------------------------------------
# Unit tests: platform token placeholder rejection
# ---------------------------------------------------------------------------
class TestPlatformTokenPlaceholderGuard:
"""Verify that _validate_gateway_config disables platforms with placeholder tokens."""
def test_rejects_triple_asterisk(self, caplog):
"""'***' is the .env.example placeholder — should be rejected."""
config = _make_gateway_config(Platform.TELEGRAM, "***")
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert config.platforms[Platform.TELEGRAM].enabled is False
assert "placeholder" in caplog.text.lower()
def test_rejects_changeme(self, caplog):
config = _make_gateway_config(Platform.DISCORD, "changeme")
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert config.platforms[Platform.DISCORD].enabled is False
def test_rejects_your_api_key(self, caplog):
config = _make_gateway_config(Platform.SLACK, "your_api_key")
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert config.platforms[Platform.SLACK].enabled is False
def test_rejects_placeholder(self, caplog):
config = _make_gateway_config(Platform.MATRIX, "placeholder")
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert config.platforms[Platform.MATRIX].enabled is False
def test_accepts_real_token(self, caplog):
"""A real-looking bot token should pass validation."""
config = _make_gateway_config(
Platform.TELEGRAM, "7123456789:AAHdqTcvCH1vGWJxfSeOfSAs0K5PALDsaw"
)
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert config.platforms[Platform.TELEGRAM].enabled is True
assert "placeholder" not in caplog.text.lower()
def test_accepts_empty_token_without_error(self, caplog):
"""Empty tokens get a warning (existing behavior), not a placeholder error."""
config = _make_gateway_config(Platform.TELEGRAM, "")
with caplog.at_level(logging.WARNING):
_validate_and_return(config)
# Empty token doesn't trigger placeholder rejection — enabled stays True
# (the existing empty-token warning is separate)
assert config.platforms[Platform.TELEGRAM].enabled is True
def test_disabled_platform_not_checked(self, caplog):
"""Disabled platforms should not be validated."""
config = _make_gateway_config(Platform.TELEGRAM, "***", enabled=False)
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert "placeholder" not in caplog.text.lower()
def test_rejects_whitespace_padded_placeholder(self, caplog):
"""Whitespace-padded placeholders should still be caught."""
config = _make_gateway_config(Platform.TELEGRAM, " *** ")
with caplog.at_level(logging.ERROR):
_validate_and_return(config)
assert config.platforms[Platform.TELEGRAM].enabled is False
# ---------------------------------------------------------------------------
# Integration test: API server placeholder key on network-accessible host
# ---------------------------------------------------------------------------
class TestAPIServerPlaceholderKeyGuard:
"""Verify that the API server rejects placeholder keys on network hosts."""
@pytest.mark.asyncio
async def test_refuses_wildcard_with_placeholder_key(self):
from gateway.platforms.api_server import APIServerAdapter
adapter = APIServerAdapter(
PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "changeme"})
)
result = await adapter.connect()
assert result is False
@pytest.mark.asyncio
async def test_refuses_wildcard_with_asterisk_key(self):
from gateway.platforms.api_server import APIServerAdapter
adapter = APIServerAdapter(
PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "***"})
)
result = await adapter.connect()
assert result is False
def test_allows_loopback_with_placeholder_key(self):
"""Loopback with a placeholder key is fine — not network-exposed."""
from gateway.platforms.api_server import APIServerAdapter
from gateway.platforms.base import is_network_accessible
adapter = APIServerAdapter(
PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "key": "changeme"})
)
# On loopback the placeholder guard doesn't fire
assert is_network_accessible(adapter._host) is False

View File

@@ -30,7 +30,7 @@ class TestWeixinFormatting:
assert (
adapter.format_message(content)
== "【Title】\n\n**Plan**\n\nUse **bold** and [docs](https://example.com)."
== "【Title】\n\n**Plan**\n\nUse **bold** and docs (https://example.com)."
)
def test_format_message_rewrites_markdown_tables(self):
@@ -374,3 +374,149 @@ class TestWeixinRemoteMediaSafety:
assert "Blocked unsafe URL" in str(exc)
else:
raise AssertionError("expected ValueError for unsafe URL")
class TestWeixinMarkdownLinks:
"""Markdown links should be converted to plaintext since WeChat can't render them."""
def test_format_message_converts_markdown_links_to_plain_text(self):
adapter = _make_adapter()
content = "Check [the docs](https://example.com) and [GitHub](https://github.com) for details"
assert (
adapter.format_message(content)
== "Check the docs (https://example.com) and GitHub (https://github.com) for details"
)
def test_format_message_preserves_links_inside_code_blocks(self):
adapter = _make_adapter()
content = "See below:\n\n```\n[link](https://example.com)\n```\n\nDone."
result = adapter.format_message(content)
assert "[link](https://example.com)" in result
class TestWeixinBlankMessagePrevention:
"""Regression tests for the blank-bubble bugs.
Three separate guards now prevent a blank WeChat message from ever being
dispatched:
1. ``_split_text_for_weixin_delivery("")`` returns ``[]`` — not ``[""]``.
2. ``send()`` filters out empty/whitespace-only chunks before calling
``_send_text_chunk``.
3. ``_send_message()`` raises ``ValueError`` for empty text as a last-resort
safety net.
"""
def test_split_text_returns_empty_list_for_empty_string(self):
adapter = _make_adapter()
assert adapter._split_text("") == []
def test_split_text_returns_empty_list_for_empty_string_split_per_line(self):
adapter = WeixinAdapter(
PlatformConfig(
enabled=True,
extra={
"account_id": "acct",
"token": "test-tok",
"split_multiline_messages": True,
},
)
)
assert adapter._split_text("") == []
@patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock)
def test_send_empty_content_does_not_call_send_message(self, send_message_mock):
adapter = _make_adapter()
adapter._session = object()
adapter._token = "test-token"
adapter._base_url = "https://weixin.example.com"
adapter._token_store.get = lambda account_id, chat_id: "ctx-token"
result = asyncio.run(adapter.send("wxid_test123", ""))
# Empty content → no chunks → no _send_message calls
assert result.success is True
send_message_mock.assert_not_awaited()
def test_send_message_rejects_empty_text(self):
"""_send_message raises ValueError for empty/whitespace text."""
import pytest
with pytest.raises(ValueError, match="text must not be empty"):
asyncio.run(
weixin._send_message(
AsyncMock(),
base_url="https://example.com",
token="tok",
to="wxid_test",
text="",
context_token=None,
client_id="cid",
)
)
class TestWeixinStreamingCursorSuppression:
"""WeChat doesn't support message editing — cursor must be suppressed."""
def test_supports_message_editing_is_false(self):
adapter = _make_adapter()
assert adapter.SUPPORTS_MESSAGE_EDITING is False
class TestWeixinMediaBuilder:
"""Media builder uses base64(hex_key), not base64(raw_bytes) for aes_key."""
def test_image_builder_aes_key_is_base64_of_hex(self):
import base64
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder("photo.jpg")
assert media_type == weixin.MEDIA_IMAGE
fake_hex_key = "0123456789abcdef0123456789abcdef"
expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii")
item = builder(
encrypt_query_param="eq",
aes_key_for_api=expected_aes,
ciphertext_size=1024,
plaintext_size=1000,
filename="photo.jpg",
rawfilemd5="abc123",
)
assert item["image_item"]["media"]["aes_key"] == expected_aes
def test_video_builder_includes_md5(self):
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder("clip.mp4")
assert media_type == weixin.MEDIA_VIDEO
item = builder(
encrypt_query_param="eq",
aes_key_for_api="fakekey",
ciphertext_size=2048,
plaintext_size=2000,
filename="clip.mp4",
rawfilemd5="deadbeef",
)
assert item["video_item"]["video_md5"] == "deadbeef"
def test_voice_builder_for_audio_files(self):
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder("note.mp3")
assert media_type == weixin.MEDIA_VOICE
item = builder(
encrypt_query_param="eq",
aes_key_for_api="fakekey",
ciphertext_size=512,
plaintext_size=500,
filename="note.mp3",
rawfilemd5="abc",
)
assert item["type"] == weixin.ITEM_VOICE
assert "voice_item" in item
def test_voice_builder_for_silk_files(self):
adapter = _make_adapter()
media_type, builder = adapter._outbound_media_builder("recording.silk")
assert media_type == weixin.MEDIA_VOICE

View File

@@ -0,0 +1,271 @@
"""Tests for WhatsApp message formatting and chunking.
Covers:
- format_message(): markdown → WhatsApp syntax conversion
- send(): message chunking for long responses
- MAX_MESSAGE_LENGTH: practical UX limit
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform, PlatformConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_adapter():
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
from gateway.platforms.whatsapp import WhatsAppAdapter
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
adapter.platform = Platform.WHATSAPP
adapter.config = MagicMock()
adapter.config.extra = {}
adapter._bridge_port = 3000
adapter._bridge_script = "/tmp/test-bridge.js"
adapter._session_path = MagicMock()
adapter._bridge_log_fh = None
adapter._bridge_log = None
adapter._bridge_process = None
adapter._reply_prefix = None
adapter._running = True
adapter._message_handler = None
adapter._fatal_error_code = None
adapter._fatal_error_message = None
adapter._fatal_error_retryable = True
adapter._fatal_error_handler = None
adapter._active_sessions = {}
adapter._pending_messages = {}
adapter._background_tasks = set()
adapter._auto_tts_disabled_chats = set()
adapter._message_queue = asyncio.Queue()
adapter._http_session = MagicMock()
adapter._mention_patterns = []
return adapter
class _AsyncCM:
"""Minimal async context manager returning a fixed value."""
def __init__(self, value):
self.value = value
async def __aenter__(self):
return self.value
async def __aexit__(self, *exc):
return False
# ---------------------------------------------------------------------------
# format_message tests
# ---------------------------------------------------------------------------
class TestFormatMessage:
"""WhatsApp markdown conversion."""
def test_bold_double_asterisk(self):
adapter = _make_adapter()
assert adapter.format_message("**hello**") == "*hello*"
def test_bold_double_underscore(self):
adapter = _make_adapter()
assert adapter.format_message("__hello__") == "*hello*"
def test_strikethrough(self):
adapter = _make_adapter()
assert adapter.format_message("~~deleted~~") == "~deleted~"
def test_headers_converted_to_bold(self):
adapter = _make_adapter()
assert adapter.format_message("# Title") == "*Title*"
assert adapter.format_message("## Subtitle") == "*Subtitle*"
assert adapter.format_message("### Deep") == "*Deep*"
def test_links_converted(self):
adapter = _make_adapter()
result = adapter.format_message("[click here](https://example.com)")
assert result == "click here (https://example.com)"
def test_code_blocks_protected(self):
"""Code blocks should not have their content reformatted."""
adapter = _make_adapter()
content = "before **bold** ```python\n**not bold**\n``` after **bold**"
result = adapter.format_message(content)
assert "```python\n**not bold**\n```" in result
assert result.startswith("before *bold*")
assert result.endswith("after *bold*")
def test_inline_code_protected(self):
"""Inline code should not have its content reformatted."""
adapter = _make_adapter()
content = "use `**raw**` here"
result = adapter.format_message(content)
assert "`**raw**`" in result
assert result.startswith("use ")
def test_empty_content(self):
adapter = _make_adapter()
assert adapter.format_message("") == ""
assert adapter.format_message(None) is None
def test_plain_text_unchanged(self):
adapter = _make_adapter()
assert adapter.format_message("hello world") == "hello world"
def test_already_whatsapp_italic(self):
"""Single *italic* should pass through unchanged."""
adapter = _make_adapter()
# After bold conversion, *text* is WhatsApp italic
assert adapter.format_message("*italic*") == "*italic*"
def test_multiline_mixed(self):
adapter = _make_adapter()
content = "# Header\n\n**Bold text** and ~~strike~~\n\n```\ncode\n```"
result = adapter.format_message(content)
assert "*Header*" in result
assert "*Bold text*" in result
assert "~strike~" in result
assert "```\ncode\n```" in result
# ---------------------------------------------------------------------------
# MAX_MESSAGE_LENGTH tests
# ---------------------------------------------------------------------------
class TestMessageLimits:
"""WhatsApp message length limits."""
def test_max_message_length_is_practical(self):
from gateway.platforms.whatsapp import WhatsAppAdapter
assert WhatsAppAdapter.MAX_MESSAGE_LENGTH == 4096
# ---------------------------------------------------------------------------
# send() chunking tests
# ---------------------------------------------------------------------------
class TestSendChunking:
"""WhatsApp send() splits long messages into chunks."""
@pytest.mark.asyncio
async def test_short_message_single_send(self):
adapter = _make_adapter()
resp = MagicMock(status=200)
resp.json = AsyncMock(return_value={"messageId": "msg1"})
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
result = await adapter.send("chat1", "short message")
assert result.success
# Only one call to bridge /send
assert adapter._http_session.post.call_count == 1
@pytest.mark.asyncio
async def test_long_message_chunked(self):
adapter = _make_adapter()
resp = MagicMock(status=200)
resp.json = AsyncMock(return_value={"messageId": "msg1"})
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
# Create a message longer than MAX_MESSAGE_LENGTH (4096)
long_msg = "a " * 3000 # ~6000 chars
result = await adapter.send("chat1", long_msg)
assert result.success
# Should have made multiple calls
assert adapter._http_session.post.call_count > 1
@pytest.mark.asyncio
async def test_empty_message_no_send(self):
adapter = _make_adapter()
result = await adapter.send("chat1", "")
assert result.success
assert adapter._http_session.post.call_count == 0
@pytest.mark.asyncio
async def test_whitespace_only_no_send(self):
adapter = _make_adapter()
result = await adapter.send("chat1", " \n ")
assert result.success
assert adapter._http_session.post.call_count == 0
@pytest.mark.asyncio
async def test_format_applied_before_send(self):
"""Markdown should be converted to WhatsApp format before sending."""
adapter = _make_adapter()
resp = MagicMock(status=200)
resp.json = AsyncMock(return_value={"messageId": "msg1"})
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
await adapter.send("chat1", "**bold text**")
# Check the payload sent to the bridge
call_args = adapter._http_session.post.call_args
payload = call_args.kwargs.get("json") or call_args[1].get("json")
assert payload["message"] == "*bold text*"
@pytest.mark.asyncio
async def test_reply_to_only_on_first_chunk(self):
"""reply_to should only be set on the first chunk."""
adapter = _make_adapter()
resp = MagicMock(status=200)
resp.json = AsyncMock(return_value={"messageId": "msg1"})
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
long_msg = "word " * 2000 # ~10000 chars, multiple chunks
await adapter.send("chat1", long_msg, reply_to="orig123")
calls = adapter._http_session.post.call_args_list
assert len(calls) > 1
# First chunk should have replyTo
first_payload = calls[0].kwargs.get("json") or calls[0][1].get("json")
assert first_payload.get("replyTo") == "orig123"
# Subsequent chunks should NOT have replyTo
for call in calls[1:]:
payload = call.kwargs.get("json") or call[1].get("json")
assert "replyTo" not in payload
@pytest.mark.asyncio
async def test_bridge_error_returns_failure(self):
adapter = _make_adapter()
resp = MagicMock(status=500)
resp.text = AsyncMock(return_value="Internal Server Error")
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
result = await adapter.send("chat1", "hello")
assert not result.success
assert "Internal Server Error" in result.error
@pytest.mark.asyncio
async def test_not_connected_returns_failure(self):
adapter = _make_adapter()
adapter._running = False
result = await adapter.send("chat1", "hello")
assert not result.success
assert "Not connected" in result.error
# ---------------------------------------------------------------------------
# display_config tier classification
# ---------------------------------------------------------------------------
class TestWhatsAppTier:
"""WhatsApp should be classified as TIER_MEDIUM."""
def test_whatsapp_streaming_follows_global(self):
from gateway.display_config import resolve_display_setting
# TIER_MEDIUM has streaming: None (follow global), not False
assert resolve_display_setting({}, "whatsapp", "streaming") is None
def test_whatsapp_tool_progress_is_new(self):
from gateway.display_config import resolve_display_setting
assert resolve_display_setting({}, "whatsapp", "tool_progress") == "new"