Skip to content

Commit 0b5aba5

Browse files
test: validate Telegram host delivery against Hermes
The previous host contract replaced send_message itself, so it could not catch drift in target parsing, MEDIA extraction, or Telegram attachment formatting. Exercise the real Hermes path and mock only the final Bot API client so generated-image delivery remains contract-backed.
1 parent c439ba2 commit 0b5aba5

3 files changed

Lines changed: 126 additions & 20 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ plugin.
1212
- Use `invoke_host_tool` for host-managed capabilities such as `send_message`;
1313
do not assume every Hermes capability is registered in `tools.registry`.
1414
Nested host calls must remain visible to `pre_tool_call` and `post_tool_call`.
15+
- Keep host invocation grounded in the real Hermes contract suite. For media
16+
delivery, exercise target parsing and platform formatting and mock only the
17+
final network client rather than replacing the host handler.
1518
- Use `tool_name(namespace, verb, noun)` for new tools and prefer explicit
1619
verbs such as `read`, `write`, and `patch`. Do not use Hermes agent-loop
1720
names (`memory`, `todo`, `session_search`, `delegate_task`) as plugin tools.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ prevents the handler from running. If the guard API is unavailable, invocation i
224224
refused rather than sending without policy checks. `send_message` is the currently
225225
supported host tool; unknown names fail explicitly.
226226

227+
The upstream Hermes contract suite runs this exact generated-image payload through
228+
the real `send_message` target parser, media extractor, and Telegram formatter. It
229+
mocks only the final Bot API client and asserts that Hermes calls `send_photo` with
230+
the numeric chat ID and generated file, without emitting a separate text message.
231+
227232
## Logging contract
228233

229234
The kit logs under the decorated handler's module logger, so each plugin can

tests/test_hermes_contract.py

Lines changed: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
satisfies the runtime contract — most importantly that a kit-built schema, run
66
through the registry's real OpenAI-tool conversion, yields a function whose
77
``parameters`` are populated (the empty-``{}`` failure mode this kit exists to
8-
prevent).
8+
prevent). The host-tool contract also sends the exact generated-image payload
9+
through Hermes' real target parser, media extractor, and Telegram formatter,
10+
mocking only the final Bot API client.
911
1012
The whole module is skipped when hermes-agent is not importable, so the suite
1113
stays green standalone and in public CI. Point it at a checkout with
@@ -22,7 +24,7 @@
2224
import types
2325
import unittest
2426
from pathlib import Path
25-
from unittest.mock import patch
27+
from unittest.mock import AsyncMock, Mock, patch
2628

2729
import hermes_plugin_kit as hpk
2830

@@ -209,24 +211,120 @@ def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None:
209211
description="Probe",
210212
)
211213

212-
def test_host_tool_invocation_uses_real_non_registry_handler(self) -> None:
213-
from tools import send_message_tool # type: ignore
214-
215-
expected = '{"success": true, "message_id": "contract-probe"}'
216-
args = {
217-
"action": "send",
218-
"target": "telegram:8670382527",
219-
"message": "MEDIA:/opt/data/avatars/generated/contract-probe.png",
220-
}
221-
with patch.object(
222-
send_message_tool,
223-
"send_message_tool",
224-
return_value=expected,
225-
) as handler:
226-
result = hpk.invoke_host_tool("send_message", args)
227-
228-
self.assertEqual(result, expected)
229-
handler.assert_called_once_with(args)
214+
def test_host_tool_invocation_reaches_real_telegram_photo_contract(self) -> None:
215+
"""Exercise Hermes parsing and Telegram formatting without network I/O."""
216+
import asyncio
217+
from tempfile import TemporaryDirectory
218+
219+
from gateway import config as gateway_config # type: ignore
220+
from gateway.config import Platform # type: ignore
221+
from tools import interrupt as interrupt_module # type: ignore
222+
223+
telegram_config = types.SimpleNamespace(
224+
enabled=True,
225+
token="contract-token",
226+
extra={},
227+
)
228+
config = types.SimpleNamespace(
229+
platforms={Platform.TELEGRAM: telegram_config},
230+
get_home_channel=lambda _platform: None,
231+
)
232+
233+
bot = types.SimpleNamespace(
234+
send_message=AsyncMock(),
235+
send_photo=AsyncMock(
236+
return_value=types.SimpleNamespace(message_id=42)
237+
),
238+
send_video=AsyncMock(),
239+
send_voice=AsyncMock(),
240+
send_audio=AsyncMock(),
241+
send_document=AsyncMock(),
242+
)
243+
bot_factory = Mock(return_value=bot)
244+
245+
telegram_module = types.ModuleType("telegram")
246+
telegram_module.Bot = bot_factory
247+
telegram_constants = types.ModuleType("telegram.constants")
248+
telegram_constants.ParseMode = types.SimpleNamespace(
249+
HTML="HTML",
250+
MARKDOWN_V2="MarkdownV2",
251+
)
252+
telegram_module.constants = telegram_constants
253+
254+
class TelegramAdapter:
255+
MAX_MESSAGE_LENGTH = 4096
256+
257+
@staticmethod
258+
def format_message(message: str) -> str:
259+
return message
260+
261+
telegram_adapter = types.ModuleType("plugins.platforms.telegram.adapter")
262+
telegram_adapter.TelegramAdapter = TelegramAdapter
263+
telegram_adapter.register = Mock()
264+
265+
model_tools = types.ModuleType("model_tools")
266+
model_tools._run_async = asyncio.run
267+
mirror = types.ModuleType("gateway.mirror")
268+
mirror.mirror_to_session = Mock(return_value=False)
269+
session_context = types.ModuleType("gateway.session_context")
270+
session_context.get_session_env = Mock(return_value="")
271+
272+
with TemporaryDirectory() as tmp:
273+
image_path = Path(tmp) / "avatars" / "generated" / "contract-probe.png"
274+
image_path.parent.mkdir(parents=True)
275+
image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
276+
args = {
277+
"action": "send",
278+
"target": "telegram:8670382527",
279+
"message": f"MEDIA:{image_path}",
280+
}
281+
282+
with (
283+
patch.dict(
284+
sys.modules,
285+
{
286+
"telegram": telegram_module,
287+
"telegram.constants": telegram_constants,
288+
"plugins.platforms.telegram.adapter": telegram_adapter,
289+
"model_tools": model_tools,
290+
"gateway.mirror": mirror,
291+
"gateway.session_context": session_context,
292+
},
293+
),
294+
patch.dict(
295+
os.environ,
296+
{
297+
"HERMES_MEDIA_DELIVERY_STRICT": "0",
298+
"TELEGRAM_PROXY": "",
299+
},
300+
clear=False,
301+
),
302+
patch.object(
303+
gateway_config,
304+
"load_gateway_config",
305+
return_value=config,
306+
),
307+
patch.object(
308+
interrupt_module,
309+
"is_interrupted",
310+
return_value=False,
311+
),
312+
):
313+
result = json.loads(hpk.invoke_host_tool("send_message", args))
314+
315+
self.assertTrue(result.get("success"), result)
316+
self.assertEqual(result["platform"], "telegram")
317+
self.assertEqual(result["chat_id"], "8670382527")
318+
self.assertEqual(result["message_id"], "42")
319+
bot_factory.assert_called_once_with(token="contract-token")
320+
bot.send_message.assert_not_awaited()
321+
bot.send_photo.assert_awaited_once()
322+
photo_call = bot.send_photo.await_args
323+
self.assertEqual(photo_call.kwargs["chat_id"], 8670382527)
324+
self.assertEqual(
325+
photo_call.kwargs["photo"].name,
326+
str(image_path.resolve()),
327+
)
230328

231329

232330
if __name__ == "__main__":

0 commit comments

Comments
 (0)