mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 01:21:43 +00:00
Extends the platform plugin interface from Phase 1 to cover every touchpoint where built-in platforms have hardcoded behavior. ## PlatformEntry extended fields - allowed_users_env / allow_all_env: per-platform auth env vars - max_message_length: smart-chunking for send_message tool - pii_safe: session PII redaction flag - emoji: CLI/gateway display - allow_update_command: /update access control ## Functional fixes (Tier 1) send_message tool (tools/send_message_tool.py): - Replaced hardcoded platform_map dict with Platform() call - Added _send_via_adapter() for plugin platforms — routes through live gateway adapter when available - Registry-aware max message length for smart chunking Cron delivery (cron/scheduler.py): - Replaced hardcoded 15-entry platform_map with Platform() call - Plugin platforms now work as cron delivery targets User authorization (gateway/run.py _is_user_authorized): - Registry fallback: checks PlatformEntry.allowed_users_env and allow_all_env when platform not in hardcoded maps - Plugin platforms get per-platform auth support ## Integration fixes (Tier 2) _UPDATE_ALLOWED_PLATFORMS: checks registry allow_update_command flag Channel directory: includes plugin platforms in session enumeration Orphaned config warning: descriptive message when plugin platform is in config but no plugin registered it Gateway weakref: _gateway_runner_ref for cross-module adapter access ## UX completeness hermes status: shows plugin platforms with (plugin) tag hermes gateway setup: plugin platforms appear in menu with setup hints hermes_cli/platforms.py: get_all_platforms() merges with registry, platform_label() falls back to registry for plugin names ## Tests - 8 new tests (extended fields, cron resolution, platforms merge) - Updated 3 tests for new Platform() based resolution - 2829 passed, 24 pre-existing failures, zero new failures
377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""Tests for the platform adapter registry and dynamic Platform enum."""
|
|
|
|
import os
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from dataclasses import dataclass
|
|
|
|
from gateway.platform_registry import PlatformRegistry, PlatformEntry, platform_registry
|
|
from gateway.config import Platform, PlatformConfig, GatewayConfig
|
|
|
|
|
|
# ── Platform enum dynamic members ─────────────────────────────────────────
|
|
|
|
|
|
class TestPlatformEnumDynamic:
|
|
"""Test that Platform enum accepts unknown values for plugin platforms."""
|
|
|
|
def test_builtin_members_still_work(self):
|
|
assert Platform.TELEGRAM.value == "telegram"
|
|
assert Platform("telegram") is Platform.TELEGRAM
|
|
|
|
def test_dynamic_member_created(self):
|
|
p = Platform("irc")
|
|
assert p.value == "irc"
|
|
assert p.name == "IRC"
|
|
|
|
def test_dynamic_member_identity_stable(self):
|
|
"""Same value returns same object (cached)."""
|
|
a = Platform("irc")
|
|
b = Platform("irc")
|
|
assert a is b
|
|
|
|
def test_dynamic_member_case_normalised(self):
|
|
"""Mixed case normalised to lowercase."""
|
|
a = Platform("IRC")
|
|
b = Platform("irc")
|
|
assert a is b
|
|
assert a.value == "irc"
|
|
|
|
def test_dynamic_member_with_hyphens(self):
|
|
p = Platform("my-platform")
|
|
assert p.value == "my-platform"
|
|
assert p.name == "MY_PLATFORM"
|
|
|
|
def test_dynamic_member_rejects_non_string(self):
|
|
with pytest.raises(ValueError):
|
|
Platform(123)
|
|
|
|
def test_dynamic_member_rejects_empty(self):
|
|
with pytest.raises(ValueError):
|
|
Platform("")
|
|
|
|
def test_dynamic_member_rejects_whitespace_only(self):
|
|
with pytest.raises(ValueError):
|
|
Platform(" ")
|
|
|
|
|
|
# ── PlatformRegistry ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPlatformRegistry:
|
|
"""Test the PlatformRegistry itself."""
|
|
|
|
def _make_entry(self, name="test", check_ok=True, validate_ok=True, factory_ok=True):
|
|
adapter_mock = MagicMock()
|
|
return PlatformEntry(
|
|
name=name,
|
|
label=name.title(),
|
|
adapter_factory=lambda cfg, _m=adapter_mock: _m if factory_ok else (_ for _ in ()).throw(RuntimeError("factory error")),
|
|
check_fn=lambda: check_ok,
|
|
validate_config=lambda cfg: validate_ok,
|
|
required_env=[],
|
|
source="plugin",
|
|
), adapter_mock
|
|
|
|
def test_register_and_get(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("alpha")
|
|
reg.register(entry)
|
|
assert reg.get("alpha") is entry
|
|
assert reg.is_registered("alpha")
|
|
|
|
def test_get_unknown_returns_none(self):
|
|
reg = PlatformRegistry()
|
|
assert reg.get("nonexistent") is None
|
|
|
|
def test_unregister(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("beta")
|
|
reg.register(entry)
|
|
assert reg.unregister("beta") is True
|
|
assert reg.get("beta") is None
|
|
assert reg.unregister("beta") is False # already gone
|
|
|
|
def test_create_adapter_success(self):
|
|
reg = PlatformRegistry()
|
|
entry, mock_adapter = self._make_entry("gamma")
|
|
reg.register(entry)
|
|
result = reg.create_adapter("gamma", MagicMock())
|
|
assert result is mock_adapter
|
|
|
|
def test_create_adapter_unknown_name(self):
|
|
reg = PlatformRegistry()
|
|
assert reg.create_adapter("unknown", MagicMock()) is None
|
|
|
|
def test_create_adapter_check_fails(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("delta", check_ok=False)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("delta", MagicMock()) is None
|
|
|
|
def test_create_adapter_validate_fails(self):
|
|
reg = PlatformRegistry()
|
|
entry, _ = self._make_entry("epsilon", validate_ok=False)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("epsilon", MagicMock()) is None
|
|
|
|
def test_create_adapter_factory_exception(self):
|
|
reg = PlatformRegistry()
|
|
entry = PlatformEntry(
|
|
name="broken",
|
|
label="Broken",
|
|
adapter_factory=lambda cfg: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
check_fn=lambda: True,
|
|
validate_config=None,
|
|
source="plugin",
|
|
)
|
|
reg.register(entry)
|
|
# factory raises → create_adapter returns None instead of propagating
|
|
assert reg.create_adapter("broken", MagicMock()) is None
|
|
|
|
def test_create_adapter_no_validate(self):
|
|
"""When validate_config is None, skip validation."""
|
|
reg = PlatformRegistry()
|
|
mock_adapter = MagicMock()
|
|
entry = PlatformEntry(
|
|
name="novalidate",
|
|
label="NoValidate",
|
|
adapter_factory=lambda cfg: mock_adapter,
|
|
check_fn=lambda: True,
|
|
validate_config=None,
|
|
source="plugin",
|
|
)
|
|
reg.register(entry)
|
|
assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter
|
|
|
|
def test_all_entries(self):
|
|
reg = PlatformRegistry()
|
|
e1, _ = self._make_entry("one")
|
|
e2, _ = self._make_entry("two")
|
|
reg.register(e1)
|
|
reg.register(e2)
|
|
names = {e.name for e in reg.all_entries()}
|
|
assert names == {"one", "two"}
|
|
|
|
def test_plugin_entries(self):
|
|
reg = PlatformRegistry()
|
|
plugin_entry, _ = self._make_entry("plugged")
|
|
builtin_entry = PlatformEntry(
|
|
name="core",
|
|
label="Core",
|
|
adapter_factory=lambda cfg: MagicMock(),
|
|
check_fn=lambda: True,
|
|
source="builtin",
|
|
)
|
|
reg.register(plugin_entry)
|
|
reg.register(builtin_entry)
|
|
plugin_names = {e.name for e in reg.plugin_entries()}
|
|
assert plugin_names == {"plugged"}
|
|
|
|
def test_re_register_replaces(self):
|
|
reg = PlatformRegistry()
|
|
entry1, mock1 = self._make_entry("dup")
|
|
entry2 = PlatformEntry(
|
|
name="dup",
|
|
label="Dup v2",
|
|
adapter_factory=lambda cfg: "v2",
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
)
|
|
reg.register(entry1)
|
|
reg.register(entry2)
|
|
assert reg.get("dup").label == "Dup v2"
|
|
|
|
|
|
# ── GatewayConfig integration ────────────────────────────────────────────
|
|
|
|
|
|
class TestGatewayConfigPluginPlatform:
|
|
"""Test that GatewayConfig parses and validates plugin platforms."""
|
|
|
|
def test_from_dict_accepts_plugin_platform(self):
|
|
data = {
|
|
"platforms": {
|
|
"telegram": {"enabled": True, "token": "test-token"},
|
|
"irc": {"enabled": True, "extra": {"server": "irc.libera.chat"}},
|
|
}
|
|
}
|
|
cfg = GatewayConfig.from_dict(data)
|
|
platform_values = {p.value for p in cfg.platforms}
|
|
assert "telegram" in platform_values
|
|
assert "irc" in platform_values
|
|
|
|
def test_get_connected_platforms_includes_registered_plugin(self):
|
|
"""Plugin platform with registry entry passes get_connected_platforms."""
|
|
# Register a fake plugin platform
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
test_entry = PlatformEntry(
|
|
name="testplat",
|
|
label="TestPlat",
|
|
adapter_factory=lambda cfg: MagicMock(),
|
|
check_fn=lambda: True,
|
|
validate_config=lambda cfg: bool(cfg.extra.get("token")),
|
|
source="plugin",
|
|
)
|
|
_reg.register(test_entry)
|
|
try:
|
|
data = {
|
|
"platforms": {
|
|
"testplat": {"enabled": True, "extra": {"token": "abc"}},
|
|
}
|
|
}
|
|
cfg = GatewayConfig.from_dict(data)
|
|
connected = cfg.get_connected_platforms()
|
|
connected_values = {p.value for p in connected}
|
|
assert "testplat" in connected_values
|
|
finally:
|
|
_reg.unregister("testplat")
|
|
|
|
def test_get_connected_platforms_excludes_unregistered_plugin(self):
|
|
"""Plugin platform without registry entry is excluded."""
|
|
data = {
|
|
"platforms": {
|
|
"unknown_plugin": {"enabled": True, "extra": {"token": "abc"}},
|
|
}
|
|
}
|
|
cfg = GatewayConfig.from_dict(data)
|
|
connected = cfg.get_connected_platforms()
|
|
connected_values = {p.value for p in connected}
|
|
assert "unknown_plugin" not in connected_values
|
|
|
|
def test_get_connected_platforms_excludes_invalid_config(self):
|
|
"""Plugin platform with failing validate_config is excluded."""
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
test_entry = PlatformEntry(
|
|
name="badconfig",
|
|
label="BadConfig",
|
|
adapter_factory=lambda cfg: MagicMock(),
|
|
check_fn=lambda: True,
|
|
validate_config=lambda cfg: False, # always fails
|
|
source="plugin",
|
|
)
|
|
_reg.register(test_entry)
|
|
try:
|
|
data = {
|
|
"platforms": {
|
|
"badconfig": {"enabled": True, "extra": {}},
|
|
}
|
|
}
|
|
cfg = GatewayConfig.from_dict(data)
|
|
connected = cfg.get_connected_platforms()
|
|
connected_values = {p.value for p in connected}
|
|
assert "badconfig" not in connected_values
|
|
finally:
|
|
_reg.unregister("badconfig")
|
|
|
|
|
|
# ── Extended PlatformEntry fields ─────────────────────────────────────
|
|
|
|
|
|
class TestPlatformEntryExtendedFields:
|
|
"""Test the auth, message length, and display fields on PlatformEntry."""
|
|
|
|
def test_default_field_values(self):
|
|
entry = PlatformEntry(
|
|
name="test",
|
|
label="Test",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
)
|
|
assert entry.allowed_users_env == ""
|
|
assert entry.allow_all_env == ""
|
|
assert entry.max_message_length == 0
|
|
assert entry.pii_safe is False
|
|
assert entry.emoji == "🔌"
|
|
assert entry.allow_update_command is True
|
|
|
|
def test_custom_auth_fields(self):
|
|
entry = PlatformEntry(
|
|
name="irc",
|
|
label="IRC",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
allowed_users_env="IRC_ALLOWED_USERS",
|
|
allow_all_env="IRC_ALLOW_ALL_USERS",
|
|
max_message_length=450,
|
|
pii_safe=False,
|
|
emoji="💬",
|
|
)
|
|
assert entry.allowed_users_env == "IRC_ALLOWED_USERS"
|
|
assert entry.allow_all_env == "IRC_ALLOW_ALL_USERS"
|
|
assert entry.max_message_length == 450
|
|
assert entry.emoji == "💬"
|
|
|
|
|
|
# ── Cron platform resolution ─────────────────────────────────────────
|
|
|
|
|
|
class TestCronPlatformResolution:
|
|
"""Test that cron delivery accepts plugin platform names."""
|
|
|
|
def test_builtin_platform_resolves(self):
|
|
"""Built-in platform names resolve via Platform() call."""
|
|
p = Platform("telegram")
|
|
assert p is Platform.TELEGRAM
|
|
|
|
def test_plugin_platform_resolves(self):
|
|
"""Plugin platform names create dynamic enum members."""
|
|
p = Platform("irc")
|
|
assert p.value == "irc"
|
|
|
|
def test_invalid_platform_type_rejected(self):
|
|
"""Non-string values are still rejected."""
|
|
with pytest.raises(ValueError):
|
|
Platform(None)
|
|
|
|
|
|
# ── platforms.py integration ──────────────────────────────────────────
|
|
|
|
|
|
class TestPlatformsMerge:
|
|
"""Test get_all_platforms() merges with registry."""
|
|
|
|
def test_get_all_platforms_includes_builtins(self):
|
|
from hermes_cli.platforms import get_all_platforms, PLATFORMS
|
|
merged = get_all_platforms()
|
|
for key in PLATFORMS:
|
|
assert key in merged
|
|
|
|
def test_get_all_platforms_includes_plugin(self):
|
|
from hermes_cli.platforms import get_all_platforms
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="testmerge",
|
|
label="TestMerge",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
emoji="🧪",
|
|
))
|
|
try:
|
|
merged = get_all_platforms()
|
|
assert "testmerge" in merged
|
|
assert "TestMerge" in merged["testmerge"].label
|
|
finally:
|
|
_reg.unregister("testmerge")
|
|
|
|
def test_platform_label_plugin_fallback(self):
|
|
from hermes_cli.platforms import platform_label
|
|
from gateway.platform_registry import platform_registry as _reg
|
|
|
|
_reg.register(PlatformEntry(
|
|
name="labeltest",
|
|
label="LabelTest",
|
|
adapter_factory=lambda cfg: None,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
emoji="🏷️",
|
|
))
|
|
try:
|
|
label = platform_label("labeltest")
|
|
assert "LabelTest" in label
|
|
finally:
|
|
_reg.unregister("labeltest")
|