Files
hermes-agent/tests/gateway/test_dm_topics.py

488 lines
14 KiB
Python
Raw Normal View History

"""Tests for Telegram DM Private Chat Topics (Bot API 9.4).
Covers:
- _setup_dm_topics: loading persisted thread_ids from config
- _setup_dm_topics: creating new topics via API when no thread_id
- _persist_dm_topic_thread_id: saving thread_id back to config.yaml
- _get_dm_topic_info: looking up topic config by thread_id
- _cache_dm_topic_from_message: caching thread_ids from incoming messages
- _build_message_event: DM topic resolution in message events
"""
import asyncio
fix: replace hardcoded ~/.hermes paths with get_hermes_home() for profile support * feat: GPT tool-use steering + strip budget warnings from history Two changes to improve tool reliability, especially for OpenAI GPT models: 1. GPT tool-use enforcement prompt: Adds GPT_TOOL_USE_GUIDANCE to the system prompt when the model name contains 'gpt' and tools are loaded. This addresses a known behavioral pattern where GPT models describe intended actions ('I will run the tests') instead of actually making tool calls. Inspired by similar steering in OpenCode (beast.txt) and Cline (GPT-5.1 variant). 2. Budget warning history stripping: Budget pressure warnings injected by _get_budget_warning() into tool results are now stripped when conversation history is replayed via run_conversation(). Previously, these turn-scoped signals persisted across turns, causing models to avoid tool calls in all subsequent messages after any turn that hit the 70-90% iteration threshold. * fix: replace hardcoded ~/.hermes paths with get_hermes_home() for profile support Prep for the upcoming profiles feature — each profile is a separate HERMES_HOME directory, so all paths must respect the env var. Fixes: - gateway/platforms/matrix.py: Matrix E2EE store was hardcoded to ~/.hermes/matrix/store, ignoring HERMES_HOME. Now uses get_hermes_home() so each profile gets its own Matrix state. - gateway/platforms/telegram.py: Two locations reading config.yaml via Path.home()/.hermes instead of get_hermes_home(). DM topic thread_id persistence and hot-reload would read the wrong config in a profile. - tools/file_tools.py: Security path for hub index blocking was hardcoded to ~/.hermes, would miss the actual profile's hub cache. - hermes_cli/gateway.py: Service naming now uses the profile name (hermes-gateway-coder) instead of a cryptic hash suffix. Extracted _profile_suffix() helper shared by systemd and launchd. - hermes_cli/gateway.py: Launchd plist path and Label now scoped per profile (ai.hermes.gateway-coder.plist). Previously all profiles would collide on the same plist file on macOS. - hermes_cli/gateway.py: Launchd plist now includes HERMES_HOME in EnvironmentVariables — was missing entirely, making custom HERMES_HOME broken on macOS launchd (pre-existing bug). - All launchctl commands in gateway.py, main.py, status.py updated to use get_launchd_label() instead of hardcoded string. Test fixes: DM topic tests now set HERMES_HOME env var alongside Path.home() mock. Launchd test uses get_launchd_label() for expected commands.
2026-03-28 13:51:08 -07:00
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch, mock_open
import pytest
from gateway.config import PlatformConfig
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
telegram_mod = MagicMock()
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
telegram_mod.constants.ChatType.GROUP = "group"
telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
telegram_mod.constants.ChatType.CHANNEL = "channel"
telegram_mod.constants.ChatType.PRIVATE = "private"
feat(telegram): auto-discover fallback IPs via DoH when api.telegram.org is unreachable (#3376) * feat(telegram): auto-discover fallback IPs via DoH when api.telegram.org is unreachable On some networks (university, corporate), api.telegram.org resolves to a valid Telegram IP that is unreachable due to routing/firewall rules. A different IP in the same Telegram-owned 149.154.160.0/20 block works fine. This adds automatic fallback IP discovery at connect time: 1. Query Google and Cloudflare DNS-over-HTTPS for api.telegram.org A records 2. Exclude the system-DNS IP (the unreachable one), use the rest as fallbacks 3. If DoH is also blocked, fall back to a seed list (149.154.167.220) 4. TelegramFallbackTransport tries primary first, sticks to whichever works No configuration needed — works automatically. TELEGRAM_FALLBACK_IPS env var still available as manual override. Zero impact on healthy networks (primary path succeeds on first attempt, fallback never exercised). No new dependencies (uses httpx already in deps + stdlib socket). * fix: share transport instance and downgrade seed fallback log to info - Use single TelegramFallbackTransport shared between request and get_updates_request so sticky IP is shared across polling and API calls - Keep separate HTTPXRequest instances (different timeout settings) - Downgrade "using seed fallback IPs" from warning to info to avoid noisy logs on healthy networks * fix: add telegram.request mock and discovery fixture to remaining test files The original PR missed test_dm_topics.py and test_telegram_network_reconnect.py — both need the telegram.request mock module. The reconnect test also needs _no_auto_discovery since _handle_polling_network_error calls connect() which now invokes discover_fallback_ips(). --------- Co-authored-by: Mohan Qiao <Gavin-Qiao@users.noreply.github.com>
2026-03-27 04:03:13 -07:00
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, telegram_mod)
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
def _make_adapter(dm_topics_config=None):
"""Create a TelegramAdapter with optional DM topics config."""
extra = {}
if dm_topics_config is not None:
extra["dm_topics"] = dm_topics_config
config = PlatformConfig(enabled=True, token="***", extra=extra)
adapter = TelegramAdapter(config)
return adapter
# ── _setup_dm_topics: load persisted thread_ids ──
@pytest.mark.asyncio
async def test_setup_dm_topics_loads_persisted_thread_ids():
"""Topics with thread_id in config should be loaded into cache, not created."""
adapter = _make_adapter([
{
"chat_id": 111,
"topics": [
{"name": "General", "thread_id": 100},
{"name": "Work", "thread_id": 200},
],
}
])
adapter._bot = AsyncMock()
await adapter._setup_dm_topics()
# Both should be in cache
assert adapter._dm_topics["111:General"] == 100
assert adapter._dm_topics["111:Work"] == 200
# create_forum_topic should NOT have been called
adapter._bot.create_forum_topic.assert_not_called()
@pytest.mark.asyncio
async def test_setup_dm_topics_creates_when_no_thread_id():
"""Topics without thread_id should be created via API."""
adapter = _make_adapter([
{
"chat_id": 222,
"topics": [
{"name": "NewTopic", "icon_color": 7322096},
],
}
])
adapter._bot = AsyncMock()
mock_topic = SimpleNamespace(message_thread_id=999)
adapter._bot.create_forum_topic.return_value = mock_topic
# Mock the persist method so it doesn't touch the filesystem
adapter._persist_dm_topic_thread_id = MagicMock()
await adapter._setup_dm_topics()
# Should have been created
adapter._bot.create_forum_topic.assert_called_once_with(
chat_id=222, name="NewTopic", icon_color=7322096,
)
# Should be in cache
assert adapter._dm_topics["222:NewTopic"] == 999
# Should persist
adapter._persist_dm_topic_thread_id.assert_called_once_with(222, "NewTopic", 999)
@pytest.mark.asyncio
async def test_setup_dm_topics_mixed_persisted_and_new():
"""Mix of persisted and new topics should work correctly."""
adapter = _make_adapter([
{
"chat_id": 333,
"topics": [
{"name": "Existing", "thread_id": 50},
{"name": "New", "icon_color": 123},
],
}
])
adapter._bot = AsyncMock()
mock_topic = SimpleNamespace(message_thread_id=777)
adapter._bot.create_forum_topic.return_value = mock_topic
adapter._persist_dm_topic_thread_id = MagicMock()
await adapter._setup_dm_topics()
# Existing loaded from config
assert adapter._dm_topics["333:Existing"] == 50
# New created via API
assert adapter._dm_topics["333:New"] == 777
# Only one API call (for "New")
adapter._bot.create_forum_topic.assert_called_once()
@pytest.mark.asyncio
async def test_setup_dm_topics_skips_empty_config():
"""Empty dm_topics config should be a no-op."""
adapter = _make_adapter([])
adapter._bot = AsyncMock()
await adapter._setup_dm_topics()
adapter._bot.create_forum_topic.assert_not_called()
assert adapter._dm_topics == {}
@pytest.mark.asyncio
async def test_setup_dm_topics_no_config():
"""No dm_topics in config at all should be a no-op."""
adapter = _make_adapter()
adapter._bot = AsyncMock()
await adapter._setup_dm_topics()
adapter._bot.create_forum_topic.assert_not_called()
# ── _create_dm_topic: error handling ──
@pytest.mark.asyncio
async def test_create_dm_topic_handles_duplicate_error():
"""Duplicate topic error should return None gracefully."""
adapter = _make_adapter()
adapter._bot = AsyncMock()
adapter._bot.create_forum_topic.side_effect = Exception("topic_name_duplicate")
result = await adapter._create_dm_topic(chat_id=111, name="General")
assert result is None
@pytest.mark.asyncio
async def test_create_dm_topic_handles_generic_error():
"""Generic error should return None with warning."""
adapter = _make_adapter()
adapter._bot = AsyncMock()
adapter._bot.create_forum_topic.side_effect = Exception("some random error")
result = await adapter._create_dm_topic(chat_id=111, name="General")
assert result is None
@pytest.mark.asyncio
async def test_create_dm_topic_returns_none_without_bot():
"""No bot instance should return None."""
adapter = _make_adapter()
adapter._bot = None
result = await adapter._create_dm_topic(chat_id=111, name="General")
assert result is None
# ── _persist_dm_topic_thread_id ──
def test_persist_dm_topic_thread_id_writes_config(tmp_path):
"""Should write thread_id into the correct topic in config.yaml."""
import yaml
config_data = {
"platforms": {
"telegram": {
"extra": {
"dm_topics": [
{
"chat_id": 111,
"topics": [
{"name": "General", "icon_color": 123},
{"name": "Work", "icon_color": 456},
],
}
]
}
}
}
}
config_file = tmp_path / ".hermes" / "config.yaml"
config_file.parent.mkdir(parents=True)
with open(config_file, "w") as f:
yaml.dump(config_data, f)
adapter = _make_adapter()
fix: replace hardcoded ~/.hermes paths with get_hermes_home() for profile support * feat: GPT tool-use steering + strip budget warnings from history Two changes to improve tool reliability, especially for OpenAI GPT models: 1. GPT tool-use enforcement prompt: Adds GPT_TOOL_USE_GUIDANCE to the system prompt when the model name contains 'gpt' and tools are loaded. This addresses a known behavioral pattern where GPT models describe intended actions ('I will run the tests') instead of actually making tool calls. Inspired by similar steering in OpenCode (beast.txt) and Cline (GPT-5.1 variant). 2. Budget warning history stripping: Budget pressure warnings injected by _get_budget_warning() into tool results are now stripped when conversation history is replayed via run_conversation(). Previously, these turn-scoped signals persisted across turns, causing models to avoid tool calls in all subsequent messages after any turn that hit the 70-90% iteration threshold. * fix: replace hardcoded ~/.hermes paths with get_hermes_home() for profile support Prep for the upcoming profiles feature — each profile is a separate HERMES_HOME directory, so all paths must respect the env var. Fixes: - gateway/platforms/matrix.py: Matrix E2EE store was hardcoded to ~/.hermes/matrix/store, ignoring HERMES_HOME. Now uses get_hermes_home() so each profile gets its own Matrix state. - gateway/platforms/telegram.py: Two locations reading config.yaml via Path.home()/.hermes instead of get_hermes_home(). DM topic thread_id persistence and hot-reload would read the wrong config in a profile. - tools/file_tools.py: Security path for hub index blocking was hardcoded to ~/.hermes, would miss the actual profile's hub cache. - hermes_cli/gateway.py: Service naming now uses the profile name (hermes-gateway-coder) instead of a cryptic hash suffix. Extracted _profile_suffix() helper shared by systemd and launchd. - hermes_cli/gateway.py: Launchd plist path and Label now scoped per profile (ai.hermes.gateway-coder.plist). Previously all profiles would collide on the same plist file on macOS. - hermes_cli/gateway.py: Launchd plist now includes HERMES_HOME in EnvironmentVariables — was missing entirely, making custom HERMES_HOME broken on macOS launchd (pre-existing bug). - All launchctl commands in gateway.py, main.py, status.py updated to use get_launchd_label() instead of hardcoded string. Test fixes: DM topic tests now set HERMES_HOME env var alongside Path.home() mock. Launchd test uses get_launchd_label() for expected commands.
2026-03-28 13:51:08 -07:00
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
adapter._persist_dm_topic_thread_id(111, "General", 999)
with open(config_file) as f:
result = yaml.safe_load(f)
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
assert topics[0]["thread_id"] == 999
assert "thread_id" not in topics[1] # "Work" should be untouched
def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):
"""Should not overwrite an existing thread_id."""
import yaml
config_data = {
"platforms": {
"telegram": {
"extra": {
"dm_topics": [
{
"chat_id": 111,
"topics": [
{"name": "General", "icon_color": 123, "thread_id": 500},
],
}
]
}
}
}
}
config_file = tmp_path / ".hermes" / "config.yaml"
config_file.parent.mkdir(parents=True)
with open(config_file, "w") as f:
yaml.dump(config_data, f)
adapter = _make_adapter()
with patch.object(Path, "home", return_value=tmp_path):
adapter._persist_dm_topic_thread_id(111, "General", 999)
with open(config_file) as f:
result = yaml.safe_load(f)
topics = result["platforms"]["telegram"]["extra"]["dm_topics"][0]["topics"]
assert topics[0]["thread_id"] == 500 # unchanged
# ── _get_dm_topic_info ──
def test_get_dm_topic_info_finds_cached_topic():
"""Should return topic config when thread_id is in cache."""
adapter = _make_adapter([
{
"chat_id": 111,
"topics": [
{"name": "General", "skill": "my-skill"},
],
}
])
adapter._dm_topics["111:General"] = 100
result = adapter._get_dm_topic_info("111", "100")
assert result is not None
assert result["name"] == "General"
assert result["skill"] == "my-skill"
def test_get_dm_topic_info_returns_none_for_unknown():
"""Should return None for unknown thread_id."""
adapter = _make_adapter([
{
"chat_id": 111,
"topics": [{"name": "General"}],
}
])
# Mock reload to avoid filesystem access
adapter._reload_dm_topics_from_config = lambda: None
result = adapter._get_dm_topic_info("111", "999")
assert result is None
def test_get_dm_topic_info_returns_none_without_config():
"""Should return None if no dm_topics config."""
adapter = _make_adapter()
adapter._reload_dm_topics_from_config = lambda: None
result = adapter._get_dm_topic_info("111", "100")
assert result is None
def test_get_dm_topic_info_returns_none_for_none_thread():
"""Should return None if thread_id is None."""
adapter = _make_adapter([
{"chat_id": 111, "topics": [{"name": "General"}]}
])
result = adapter._get_dm_topic_info("111", None)
assert result is None
def test_get_dm_topic_info_hot_reloads_from_config(tmp_path):
"""Should find a topic added to config after startup (hot-reload)."""
import yaml
# Start with empty topics
adapter = _make_adapter([
{"chat_id": 111, "topics": []}
])
# Write config with a new topic + thread_id
config_data = {
"platforms": {
"telegram": {
"extra": {
"dm_topics": [
{
"chat_id": 111,
"topics": [
{"name": "NewProject", "thread_id": 555},
],
}
]
}
}
}
}
config_file = tmp_path / ".hermes" / "config.yaml"
config_file.parent.mkdir(parents=True)
with open(config_file, "w") as f:
yaml.dump(config_data, f)
fix: replace hardcoded ~/.hermes paths with get_hermes_home() for profile support * feat: GPT tool-use steering + strip budget warnings from history Two changes to improve tool reliability, especially for OpenAI GPT models: 1. GPT tool-use enforcement prompt: Adds GPT_TOOL_USE_GUIDANCE to the system prompt when the model name contains 'gpt' and tools are loaded. This addresses a known behavioral pattern where GPT models describe intended actions ('I will run the tests') instead of actually making tool calls. Inspired by similar steering in OpenCode (beast.txt) and Cline (GPT-5.1 variant). 2. Budget warning history stripping: Budget pressure warnings injected by _get_budget_warning() into tool results are now stripped when conversation history is replayed via run_conversation(). Previously, these turn-scoped signals persisted across turns, causing models to avoid tool calls in all subsequent messages after any turn that hit the 70-90% iteration threshold. * fix: replace hardcoded ~/.hermes paths with get_hermes_home() for profile support Prep for the upcoming profiles feature — each profile is a separate HERMES_HOME directory, so all paths must respect the env var. Fixes: - gateway/platforms/matrix.py: Matrix E2EE store was hardcoded to ~/.hermes/matrix/store, ignoring HERMES_HOME. Now uses get_hermes_home() so each profile gets its own Matrix state. - gateway/platforms/telegram.py: Two locations reading config.yaml via Path.home()/.hermes instead of get_hermes_home(). DM topic thread_id persistence and hot-reload would read the wrong config in a profile. - tools/file_tools.py: Security path for hub index blocking was hardcoded to ~/.hermes, would miss the actual profile's hub cache. - hermes_cli/gateway.py: Service naming now uses the profile name (hermes-gateway-coder) instead of a cryptic hash suffix. Extracted _profile_suffix() helper shared by systemd and launchd. - hermes_cli/gateway.py: Launchd plist path and Label now scoped per profile (ai.hermes.gateway-coder.plist). Previously all profiles would collide on the same plist file on macOS. - hermes_cli/gateway.py: Launchd plist now includes HERMES_HOME in EnvironmentVariables — was missing entirely, making custom HERMES_HOME broken on macOS launchd (pre-existing bug). - All launchctl commands in gateway.py, main.py, status.py updated to use get_launchd_label() instead of hardcoded string. Test fixes: DM topic tests now set HERMES_HOME env var alongside Path.home() mock. Launchd test uses get_launchd_label() for expected commands.
2026-03-28 13:51:08 -07:00
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
result = adapter._get_dm_topic_info("111", "555")
assert result is not None
assert result["name"] == "NewProject"
# Should now be cached
assert adapter._dm_topics["111:NewProject"] == 555
# ── _cache_dm_topic_from_message ──
def test_cache_dm_topic_from_message():
"""Should cache a new topic mapping."""
adapter = _make_adapter()
adapter._cache_dm_topic_from_message("111", "100", "General")
assert adapter._dm_topics["111:General"] == 100
def test_cache_dm_topic_from_message_no_overwrite():
"""Should not overwrite an existing cached topic."""
adapter = _make_adapter()
adapter._dm_topics["111:General"] = 100
adapter._cache_dm_topic_from_message("111", "999", "General")
assert adapter._dm_topics["111:General"] == 100 # unchanged
# ── _build_message_event: auto_skill binding ──
def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None,
user_id=42, user_name="Test User", forum_topic_created=None):
"""Create a mock Telegram Message for _build_message_event tests."""
chat = SimpleNamespace(
id=chat_id,
type=chat_type,
title=None,
)
# Add full_name attribute for DM chats
if not hasattr(chat, "full_name"):
chat.full_name = user_name
user = SimpleNamespace(
id=user_id,
full_name=user_name,
)
msg = SimpleNamespace(
chat=chat,
from_user=user,
text=text,
message_thread_id=thread_id,
message_id=1001,
reply_to_message=None,
date=None,
forum_topic_created=forum_topic_created,
)
return msg
def test_build_message_event_sets_auto_skill():
"""When topic has a skill binding, auto_skill should be set on the event."""
from gateway.platforms.base import MessageType
adapter = _make_adapter([
{
"chat_id": 111,
"topics": [
{"name": "My Project", "skill": "accessibility-auditor", "thread_id": 100},
],
}
])
adapter._dm_topics["111:My Project"] = 100
msg = _make_mock_message(chat_id=111, thread_id=100, text="check this page")
event = adapter._build_message_event(msg, MessageType.TEXT)
assert event.auto_skill == "accessibility-auditor"
# chat_topic should be the clean topic name, no [skill: ...] suffix
assert event.source.chat_topic == "My Project"
def test_build_message_event_no_auto_skill_without_binding():
"""Topics without skill binding should have auto_skill=None."""
from gateway.platforms.base import MessageType
adapter = _make_adapter([
{
"chat_id": 111,
"topics": [
{"name": "General", "thread_id": 200},
],
}
])
adapter._dm_topics["111:General"] = 200
msg = _make_mock_message(chat_id=111, thread_id=200)
event = adapter._build_message_event(msg, MessageType.TEXT)
assert event.auto_skill is None
assert event.source.chat_topic == "General"
def test_build_message_event_no_auto_skill_without_thread():
"""Regular DM messages (no thread_id) should have auto_skill=None."""
from gateway.platforms.base import MessageType
adapter = _make_adapter()
msg = _make_mock_message(chat_id=111, thread_id=None)
event = adapter._build_message_event(msg, MessageType.TEXT)
assert event.auto_skill is None