hermes-agent/tests/hermes_cli/test_status_model_provider.py
emozilla 984e6cb5b8 feat(whatsapp): add WhatsApp Business Cloud API adapter
Add an official, production-grade WhatsApp integration via Meta's
Business Cloud API as a complement to the existing Baileys bridge.
No bridge subprocess, no QR codes, no account-ban risk — at the cost
of a Meta Business account and a public HTTPS webhook URL.

Setup is fully wizard-driven: 'hermes whatsapp-cloud' walks through
every credential with paste-time validation (catches the #1 trap of
pasting a phone number into the Phone Number ID field), generates a
verify token, and ends with copy-paste instructions for the
cloudflared / Meta-dashboard / Business Manager pieces that can't be
automated. The wizard also points users at Meta's Business Manager
for setting the bot's display name and profile picture.

Feature set:

- Inbound: text, images (with native-vision routing), voice notes
  (STT), documents (small text inlined, larger cached), reply context.
- Outbound: text with WhatsApp-flavored markdown conversion, images,
  videos, documents, opus voice notes via ffmpeg with MP3 fallback.
- Native interactive buttons for clarify, dangerous-command approval,
  and slash-command confirmation flows — matches the Telegram /
  Discord UX, graceful degrades to plain text.
- Read receipts (blue double-checkmarks) and typing indicator,
  using Meta's combined endpoint so they fire in a single API call.
- Webhook security: X-Hub-Signature-256 HMAC verification (raw body,
  constant-time), wamid deduplication, group-shaped-message refusal
  (groups deferred to v2 — Baileys still covers them).
- Full integration with the gateway's session, cron, display-tier,
  prompt-hint, and auth-allowlist systems. Cloud and Baileys can run
  side-by-side against different phone numbers.

Also wires STT (speech-to-text) through Nous's managed audio gateway
for Nous subscribers — previously the default stt.provider=local
required a separate faster-whisper install. New subscribers now get
voice-note transcription out of the box.

Docs: 418-line user guide at website/docs/user-guide/messaging/
whatsapp-cloud.md, sidebar entry, environment-variables reference,
ADDING_A_PLATFORM.md updated with the optional interactive-UX
contract for future adapter authors.

Tests: 100 dedicated tests for the adapter, 32 for the setup wizard,
20 for the Nous subscription STT wiring, plus regression coverage
across display_config, prompt_builder, and the cron scheduler.

Known limitations (deferred until clear demand signal):
- Group chats — use the Baileys bridge if you need them.
- Message templates for 24-hour-window outside-conversation sends —
  reactive chat is unaffected; cron / delegate_task with gaps > 24h
  will fail with a clear error. The agent's system prompt warns the
  model about this so it knows to mention it when scheduling delayed
  messages.
2026-05-23 01:07:01 -04:00

156 lines
7 KiB
Python

"""Tests for hermes_cli.status model/provider display."""
from types import SimpleNamespace
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""):
import hermes_cli.auth as auth_mod
monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
def _get_env_value(name: str):
if name == "OPENAI_BASE_URL":
return openai_base_url
return ""
monkeypatch.setattr(status_mod, "get_env_value", _get_env_value, raising=False)
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(
status_mod.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(stdout="inactive\n", returncode=3),
)
def test_show_status_displays_configured_dict_model_and_provider_label(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
status_mod,
"load_config",
lambda: {"model": {"default": "anthropic/claude-sonnet-4", "provider": "anthropic"}},
raising=False,
)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "anthropic", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "anthropic", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Anthropic", raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Model: anthropic/claude-sonnet-4" in out
assert "Provider: Anthropic" in out
def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path, openai_base_url="http://localhost:8080/v1")
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "qwen3:latest"}, raising=False)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "auto", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openrouter", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Custom endpoint" if provider == "custom" else provider, raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Model: qwen3:latest" in out
assert "Provider: Custom endpoint" in out
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: True)
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
status_mod,
"load_config",
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
raising=False,
)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
monkeypatch.setattr(
status_mod,
"get_nous_subscription_features",
lambda config: NousSubscriptionFeatures(
subscribed=True,
nous_auth_present=True,
provider_is_nous=True,
features={
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"stt": NousFeatureState("stt", "Speech-to-text", True, True, True, True, False, True, "OpenAI Whisper"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
},
),
raising=False,
)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Tool Gateway" in out
assert "Browser automation" in out
assert "active via Nous subscription" in out
def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path):
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: False)
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
status_mod,
"load_config",
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
raising=False,
)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Tool Gateway" not in out
def test_show_status_reports_empty_lmstudio_listing_as_reachable(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
status_mod,
"load_config",
lambda: {
"model": {
"default": "qwen/qwen3-coder-30b",
"provider": "lmstudio",
"base_url": "http://127.0.0.1:1234/v1",
}
},
raising=False,
)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "lmstudio", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "lmstudio", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "LM Studio", raising=False)
monkeypatch.setattr(
"hermes_cli.models.probe_lmstudio_models",
lambda api_key=None, base_url=None, timeout=5.0: [],
)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "LM Studio" in out
assert "reachable (0 model(s)) at http://127.0.0.1:1234/v1" in out