chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:56:03 +08:00
commit b4fbd6fe9f
6241 changed files with 2261833 additions and 0 deletions
View File
View File
@@ -0,0 +1,273 @@
"""Behavior-parity check for the browser-provider plugin migration (#25214).
Spawns one subprocess per (version, scenario) cell — pinned to either
origin/main (legacy in-tree providers + class-instantiation lookup) or
this PR's worktree (plugin-based registry) via `sys.path[0]`. Each
subprocess clears all browser-related env vars + writes a config.yaml,
loads `tools.browser_tool._get_cloud_provider()`, and emits a reduced
"shape tuple" {is_local, provider_name, is_available} as JSON.
The parent process diffs the shapes per scenario. A diff means the
migration introduced an observable behaviour change vs origin/main —
which would be a real regression for users on the existing config keys.
Run from the PR worktree:
cd ~/.hermes/hermes-agent/.worktrees/browser-providers-plugin
python tests/plugins/browser/check_parity_vs_main.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
# Pin one path to current main, one to the PR worktree.
# ``REPO_ROOT`` is ``.../.worktrees/browser-providers-plugin``; the main
# checkout lives two levels up at ``~/.hermes/hermes-agent``.
MAIN_DIR = REPO_ROOT.parent.parent # ~/.hermes/hermes-agent
PR_DIR = REPO_ROOT # the worktree we're in
assert (MAIN_DIR / "tools" / "browser_tool.py").exists(), (
f"MAIN_DIR={MAIN_DIR} doesn't look like a hermes-agent checkout"
)
assert (PR_DIR / "tools" / "browser_tool.py").exists(), (
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
)
# Reduced shape comparison — exact instance addresses obviously differ
# between subprocesses, so we compare the parts that matter for users.
SUBPROCESS_SCRIPT = r"""
import json, os, sys, tempfile
sys.path.insert(0, sys.argv[1])
# Isolated HERMES_HOME for the config write.
home = tempfile.mkdtemp()
os.environ["HERMES_HOME"] = home
# Clear every browser-related env var so is_available() is deterministic.
for k in (
"BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSERBASE_BASE_URL",
"BROWSER_USE_API_KEY", "BROWSER_USE_GATEWAY_URL",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_BROWSER_TTL",
"TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(k, None)
# Apply per-scenario env (passed as JSON via argv[2]).
scenario_env = json.loads(sys.argv[2])
os.environ.update(scenario_env)
# Apply per-scenario config (passed as YAML body via argv[3]).
config_yaml = sys.argv[3]
config_path = os.path.join(home, "config.yaml")
with open(config_path, "w") as f:
f.write(config_yaml)
# Fresh import — must not have any browser modules cached.
for name in list(sys.modules):
if name.startswith("tools.") or name.startswith("agent.") or name.startswith("plugins."):
sys.modules.pop(name, None)
from tools.browser_tool import _get_cloud_provider, _is_local_mode
provider = _get_cloud_provider()
# Pull the human-readable backend name via the API that exists on BOTH
# legacy (origin/main: CloudBrowserProvider.provider_name()) and the new
# ABC (BrowserProvider exposes provider_name() as a backward-compat alias
# returning display_name). Both shapes resolve to the same string —
# 'Browserbase' / 'Browser Use' / 'Firecrawl' — so we can compare safely.
provider_name = None
is_available = None
if provider is not None:
pn = getattr(provider, "provider_name", None)
if callable(pn):
provider_name = pn()
elif isinstance(pn, str):
provider_name = pn
is_conf = getattr(provider, "is_configured", None)
if callable(is_conf):
is_available = bool(is_conf())
shape = {
"is_local": _is_local_mode(),
"provider_name": provider_name,
"is_available": is_available,
}
print(json.dumps(shape))
"""
SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
# (label, config.yaml body, extra env vars)
("no-config-no-env", "", {}),
("explicit-local-no-env", "browser:\n cloud_provider: local\n", {}),
(
"explicit-browserbase-no-creds",
"browser:\n cloud_provider: browserbase\n",
{},
),
(
"explicit-browserbase-with-creds",
"browser:\n cloud_provider: browserbase\n",
{"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
),
(
"explicit-browser-use-no-creds",
"browser:\n cloud_provider: browser-use\n",
{},
),
(
"explicit-browser-use-with-creds",
"browser:\n cloud_provider: browser-use\n",
{"BROWSER_USE_API_KEY": "k"},
),
(
"explicit-firecrawl-no-creds",
"browser:\n cloud_provider: firecrawl\n",
{},
),
(
"explicit-firecrawl-with-creds",
"browser:\n cloud_provider: firecrawl\n",
{"FIRECRAWL_API_KEY": "k"},
),
(
"no-config-bu-creds",
"",
{"BROWSER_USE_API_KEY": "k"},
),
(
"no-config-bb-creds",
"",
{"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
),
(
"no-config-both-creds",
"",
{
"BROWSER_USE_API_KEY": "k",
"BROWSERBASE_API_KEY": "x",
"BROWSERBASE_PROJECT_ID": "y",
},
),
(
"no-config-firecrawl-only",
"",
{"FIRECRAWL_API_KEY": "k"},
),
(
"no-config-firecrawl-and-bb",
"",
{
"FIRECRAWL_API_KEY": "k",
"BROWSERBASE_API_KEY": "x",
"BROWSERBASE_PROJECT_ID": "y",
},
),
]
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
"""Run one (version, scenario) cell. Returns the shape dict."""
venv_python = repo_path / ".venv" / "bin" / "python"
if not venv_python.exists():
# Worktrees share the main repo's venv.
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = Path("python3")
out = subprocess.run(
[
str(venv_python),
"-c",
SUBPROCESS_SCRIPT,
str(repo_path),
json.dumps(env),
config_yaml,
],
capture_output=True,
text=True,
timeout=30,
)
if out.returncode != 0:
return {
"error": "subprocess failed",
"stdout": out.stdout,
"stderr": out.stderr[-500:],
}
try:
return json.loads(out.stdout.strip().splitlines()[-1])
except Exception as exc:
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
def _reduce_for_comparison(shape: dict) -> dict:
"""Reduce a shape dict to the parts that matter for user-visible parity.
We compare ``(is_local, provider_name, is_available)`` — the trio that
decides what the dispatcher does with each tool call. ``provider_name``
is the legacy ``provider_name()`` return value ('Browserbase' / 'Browser
Use' / 'Firecrawl'), which is identical between legacy and plugin
classes (the plugin's ``display_name`` matches the legacy
``provider_name()`` return).
"""
return {
"is_local": shape.get("is_local"),
"provider_name": shape.get("provider_name"),
"is_available": shape.get("is_available"),
}
def main() -> int:
print(f"main: {MAIN_DIR}")
print(f"pr: {PR_DIR}")
print()
failures: list[str] = []
errors: list[str] = []
for label, config_yaml, env in SCENARIOS:
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
if "error" in main_shape or "error" in pr_shape:
print(f" [ERR ] {label}: subprocess failed")
print(f" main: {main_shape}")
print(f" pr: {pr_shape}")
errors.append(label)
continue
main_reduced = _reduce_for_comparison(main_shape)
pr_reduced = _reduce_for_comparison(pr_shape)
if main_reduced == pr_reduced:
print(f" [OK] {label}: {main_reduced}")
else:
print(f" [FAIL] {label}")
print(f" main: {main_reduced}")
print(f" pr: {pr_reduced}")
failures.append(label)
print()
if errors:
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
for e in errors:
print(f" - {e}")
if failures:
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
for f in failures:
print(f" - {f}")
if failures or errors:
return 1
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,379 @@
"""Plugin-side tests for the browser provider migration (PR #25214).
Covers:
- All three bundled plugins (browserbase, browser-use, firecrawl)
instantiate and self-report the expected ABC defaults.
- Each plugin's ``is_available()`` correctly reflects env-var presence.
- The browser_registry resolves an active provider in the documented
scenarios:
* explicit config wins ignoring availability (so dispatcher surfaces
a typed credentials error)
* legacy preference walk: browser-use → browserbase (filtered by
availability)
* firecrawl is NOT in the legacy walk — explicit-only
* unknown name falls through to auto-detect
* ``local`` short-circuits to None
These tests use *real* imports from the plugin modules — no mocking of
provider classes themselves — so the test catches drift in the ABC
interface, the registry, and the plugin glue layer simultaneously.
Mirrors ``tests/plugins/web/test_web_search_provider_plugins.py`` from
PR #25182.
"""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clear_browser_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Strip every browser-provider env var so is_available() returns False."""
for k in (
"BROWSERBASE_API_KEY",
"BROWSERBASE_PROJECT_ID",
"BROWSERBASE_BASE_URL",
"BROWSER_USE_API_KEY",
"BROWSER_USE_GATEWAY_URL",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_BROWSER_TTL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_USER_TOKEN",
):
monkeypatch.delenv(k, raising=False)
def _ensure_plugins_loaded() -> None:
"""Idempotently load plugins so the registry is populated."""
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
# ---------------------------------------------------------------------------
# Per-test isolation
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Each test starts with a clean browser-provider env."""
_clear_browser_env(monkeypatch)
# ---------------------------------------------------------------------------
# Bundled plugins register
# ---------------------------------------------------------------------------
class TestBundledPluginsRegister:
"""All three bundled browser plugins discover and register correctly."""
def test_all_three_plugins_present_in_registry(self) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import list_providers
names = sorted(p.name for p in list_providers())
assert names == ["browser-use", "browserbase", "firecrawl"]
@pytest.mark.parametrize(
"plugin_name,expected_display",
[
("browserbase", "Browserbase"),
("browser-use", "Browser Use"),
("firecrawl", "Firecrawl"),
],
)
def test_each_plugin_has_name_and_display_name(
self, plugin_name: str, expected_display: str
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None, f"plugin {plugin_name!r} not registered"
assert provider.name == plugin_name
assert provider.display_name == expected_display
@pytest.mark.parametrize(
"plugin_name",
["browserbase", "browser-use", "firecrawl"],
)
def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
"""``get_setup_schema()`` returns a dict the picker can consume."""
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None
schema = provider.get_setup_schema()
assert isinstance(schema, dict)
assert "name" in schema
assert "env_vars" in schema
# Every cloud-browser plugin needs the agent-browser post-setup hook
# so the picker auto-installs the CLI on selection.
assert schema.get("post_setup") == "agent_browser"
@pytest.mark.parametrize(
"plugin_name",
["browserbase", "browser-use", "firecrawl"],
)
def test_each_plugin_implements_full_lifecycle(self, plugin_name: str) -> None:
"""The ABC's three lifecycle methods are all overridden."""
_ensure_plugins_loaded()
from agent.browser_provider import BrowserProvider
from agent.browser_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None
# Each method must be a real override, not the ABC's NotImplementedError
# default — we check by comparing the function reference.
assert type(provider).create_session is not BrowserProvider.create_session
assert type(provider).close_session is not BrowserProvider.close_session
assert (
type(provider).emergency_cleanup is not BrowserProvider.emergency_cleanup
)
# ---------------------------------------------------------------------------
# is_available() behavior
# ---------------------------------------------------------------------------
class TestIsAvailable:
"""Each plugin's ``is_available()`` reflects env-var presence accurately."""
def test_browserbase_requires_both_api_key_and_project_id(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("browserbase")
assert p is not None
assert p.is_available() is False
# API key alone is insufficient.
monkeypatch.setenv("BROWSERBASE_API_KEY", "key")
assert p.is_available() is False
# Both env vars set → available.
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
assert p.is_available() is True
def test_browserbase_project_id_alone_insufficient(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("browserbase")
assert p is not None
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
assert p.is_available() is False
def test_browser_use_satisfied_by_api_key(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("browser-use")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("BROWSER_USE_API_KEY", "key")
assert p.is_available() is True
def test_firecrawl_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("firecrawl")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("FIRECRAWL_API_KEY", "key")
assert p.is_available() is True
# ---------------------------------------------------------------------------
# Registry resolution semantics
# ---------------------------------------------------------------------------
class TestRegistryResolution:
"""``_resolve()`` implements the documented three-rule precedence."""
def test_resolve_none_with_no_creds_returns_none(self) -> None:
"""No config, no env → local mode (None)."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
assert _resolve(None) is None
def test_explicit_local_returns_none(self) -> None:
"""``cloud_provider: local`` is a positive choice; short-circuits to None."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
assert _resolve("local") is None
def test_explicit_browserbase_returns_provider_even_when_unavailable(self) -> None:
"""Rule 1: explicit-config wins even when credentials are missing.
This is critical — the dispatcher needs to surface a typed
credentials error rather than silently switching backends.
"""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
provider = _resolve("browserbase")
assert provider is not None
assert provider.name == "browserbase"
assert provider.is_available() is False # confirms "ignoring availability"
def test_explicit_firecrawl_returns_provider_even_when_unavailable(self) -> None:
"""Firecrawl behaves the same as browserbase under explicit config."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
provider = _resolve("firecrawl")
assert provider is not None
assert provider.name == "firecrawl"
def test_explicit_unknown_falls_back_to_auto_detect(self) -> None:
"""Rule 1 miss: unknown name → fall through to legacy walk."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
# With no credentials anywhere, auto-detect should also fail.
assert _resolve("not-a-real-provider") is None
def test_legacy_walk_prefers_browser_use_over_browserbase(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rule 3: walk order is browser-use → browserbase."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
# Both available — browser-use should win.
monkeypatch.setenv("BROWSER_USE_API_KEY", "k1")
monkeypatch.setenv("BROWSERBASE_API_KEY", "k2")
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
provider = _resolve(None)
assert provider is not None
assert provider.name == "browser-use"
def test_legacy_walk_falls_through_to_browserbase(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rule 3: browser-use unavailable → browserbase picked."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
monkeypatch.setenv("BROWSERBASE_API_KEY", "k")
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
provider = _resolve(None)
assert provider is not None
assert provider.name == "browserbase"
def test_firecrawl_not_in_legacy_walk_even_when_only_one_available(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression: firecrawl is NEVER auto-selected even when single-eligible.
Pre-PR-#25214, the dispatcher only auto-detected between Browser Use
and Browserbase; firecrawl was reachable solely via explicit
config. We preserve that gate because FIRECRAWL_API_KEY is shared
with the *web* firecrawl plugin — auto-routing a web-extract user
to a paid cloud browser would be a real behaviour regression.
"""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
monkeypatch.setenv("FIRECRAWL_API_KEY", "k")
# Only firecrawl is_available() — but it's not in the legacy walk.
assert _resolve(None) is None
# ---------------------------------------------------------------------------
# Legacy ABC backward-compat aliases (is_configured / provider_name)
# ---------------------------------------------------------------------------
class TestLegacyAbcAliases:
"""is_configured() and provider_name() delegate to the new API."""
@pytest.mark.parametrize(
"plugin_name",
["browserbase", "browser-use", "firecrawl"],
)
def test_is_configured_delegates_to_is_available(self, plugin_name: str) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider(plugin_name)
assert p is not None
assert p.is_configured() is p.is_available()
@pytest.mark.parametrize(
"plugin_name,expected_label",
[
("browserbase", "Browserbase"),
("browser-use", "Browser Use"),
("firecrawl", "Firecrawl"),
],
)
def test_provider_name_returns_display_name(
self, plugin_name: str, expected_label: str
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider(plugin_name)
assert p is not None
assert p.provider_name() == expected_label
# ---------------------------------------------------------------------------
# Picker integration
# ---------------------------------------------------------------------------
class TestPickerIntegration:
"""`_plugin_browser_providers()` exposes all three plugins as picker rows."""
def test_picker_rows_match_registered_plugins(self) -> None:
_ensure_plugins_loaded()
from hermes_cli.tools_config import _plugin_browser_providers
rows = _plugin_browser_providers()
names = sorted(r.get("browser_provider") for r in rows)
assert names == ["browser-use", "browserbase", "firecrawl"]
def test_picker_rows_carry_post_setup_hook(self) -> None:
"""Every browser plugin row has post_setup='agent_browser' so
selecting it triggers the agent-browser CLI install."""
_ensure_plugins_loaded()
from hermes_cli.tools_config import _plugin_browser_providers
for row in _plugin_browser_providers():
assert row.get("post_setup") == "agent_browser", (
f"plugin row {row['browser_provider']!r} missing post_setup hook"
)
def test_picker_rows_carry_browser_plugin_name_marker(self) -> None:
"""`browser_plugin_name` matches `browser_provider` so downstream
code can route through the registry when it wants to."""
_ensure_plugins_loaded()
from hermes_cli.tools_config import _plugin_browser_providers
for row in _plugin_browser_providers():
assert row.get("browser_plugin_name") == row.get("browser_provider")
@@ -0,0 +1,246 @@
"""Tests for the BasicAuthProvider plugin (username/password, scrypt, signed
tokens).
Loads the plugin module directly (it's a bundled backend plugin, not on the
import path as a package) and exercises the provider behaviour + the
``register(ctx)`` entry point's config/env resolution and skip reasons.
"""
from __future__ import annotations
import secrets
from unittest.mock import MagicMock
import pytest
import plugins.dashboard_auth.basic as basic_plugin
from hermes_cli.dashboard_auth import (
InvalidCredentialsError,
RefreshExpiredError,
assert_protocol_compliance,
)
@pytest.fixture(scope="module")
def basic():
return basic_plugin
@pytest.fixture(autouse=True)
def _clear_basic_env(monkeypatch):
for var in (
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME",
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH",
"HERMES_DASHBOARD_BASIC_AUTH_SECRET",
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS",
):
monkeypatch.delenv(var, raising=False)
# ---------------------------------------------------------------------------
# Hashing
# ---------------------------------------------------------------------------
class TestPasswordHashing:
def test_hash_then_verify_round_trips(self, basic):
h = basic.hash_password("hunter2")
assert h.startswith("scrypt$")
assert basic._verify_password("hunter2", h)
def test_wrong_password_fails(self, basic):
h = basic.hash_password("hunter2")
assert not basic._verify_password("wrong", h)
def test_malformed_hash_returns_false(self, basic):
assert not basic._verify_password("x", "not-a-valid-hash")
assert not basic._verify_password("x", "bcrypt$wrong$scheme")
def test_two_hashes_of_same_password_differ(self, basic):
# Distinct random salts → distinct encoded hashes.
assert basic.hash_password("pw") != basic.hash_password("pw")
# ---------------------------------------------------------------------------
# Provider behaviour
# ---------------------------------------------------------------------------
class TestProvider:
def _make(self, basic, **kw):
h = basic.hash_password("hunter2")
return basic.BasicAuthProvider(
username="admin",
password_hash=h,
secret=secrets.token_bytes(32),
**kw,
)
def test_protocol_compliant(self, basic):
assert assert_protocol_compliance(basic.BasicAuthProvider) is None
def test_supports_password_true(self, basic):
assert basic.BasicAuthProvider.supports_password is True
def test_login_mints_session(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert s.user_id == "admin"
assert s.provider == "basic"
assert s.access_token and s.refresh_token
def test_bad_credentials_raise(self, basic):
p = self._make(basic)
for u, pw in [("admin", "wrong"), ("ghost", "hunter2"), ("", "")]:
with pytest.raises(InvalidCredentialsError):
p.complete_password_login(username=u, password=pw)
def test_verify_round_trips_and_rejects_tamper(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert p.verify_session(access_token=s.access_token) is not None
assert p.verify_session(access_token="garbage") is None
def test_access_token_not_accepted_as_refresh(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
# A refresh token must not verify as an access token and vice
# versa — the ``kind`` claim is enforced.
assert p.verify_session(access_token=s.refresh_token) is None
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token=s.access_token)
def test_refresh_round_trips(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
r = p.refresh_session(refresh_token=s.refresh_token)
assert r.user_id == "admin"
assert p.verify_session(access_token=r.access_token) is not None
def test_refresh_with_garbage_raises(self, basic):
p = self._make(basic)
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token="garbage")
def test_cross_secret_token_does_not_verify(self, basic):
p1 = self._make(basic)
p2 = self._make(basic) # different random secret
s = p1.complete_password_login(username="admin", password="hunter2")
assert p2.verify_session(access_token=s.access_token) is None
def test_revoke_is_silent(self, basic):
p = self._make(basic)
p.revoke_session(refresh_token="anything") # must not raise
def test_oauth_methods_raise_not_implemented(self, basic):
p = self._make(basic)
with pytest.raises(NotImplementedError):
p.start_login(redirect_uri="https://x/auth/callback")
with pytest.raises(NotImplementedError):
p.complete_login(
code="c", state="s", code_verifier="v", redirect_uri="r"
)
def test_construction_validates_inputs(self, basic):
good_hash = basic.hash_password("pw")
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="", password_hash=good_hash, secret=b"x" * 32
)
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="admin", password_hash="", secret=b"x" * 32
)
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="admin", password_hash=good_hash, secret=b"short"
)
# ---------------------------------------------------------------------------
# register() entry point — config/env resolution + skip reasons
# ---------------------------------------------------------------------------
class TestRegister:
def test_skips_when_no_username(self, basic, monkeypatch):
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "username" in basic.LAST_SKIP_REASON
def test_skips_when_username_but_no_password(self, basic, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "password" in basic.LAST_SKIP_REASON
def test_registers_with_env_plaintext_password(self, basic, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert isinstance(provider, basic.BasicAuthProvider)
# Round-trips: the registered provider authenticates the env creds.
s = provider.complete_password_login(username="admin", password="hunter2")
assert s.user_id == "admin"
assert basic.LAST_SKIP_REASON == ""
def test_registers_with_precomputed_hash(self, basic, monkeypatch):
h = basic.hash_password("s3cret")
monkeypatch.setattr(
basic,
"_load_config_basic_auth_section",
lambda: {"username": "ops", "password_hash": h},
)
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert provider.complete_password_login(
username="ops", password="s3cret"
).user_id == "ops"
def test_env_password_overrides_config(self, basic, monkeypatch):
cfg_hash = basic.hash_password("config-pw")
monkeypatch.setattr(
basic,
"_load_config_basic_auth_section",
lambda: {"username": "admin", "password_hash": cfg_hash},
)
# Env plaintext should win over the config hash.
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "env-pw")
ctx = MagicMock()
basic.register(ctx)
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
# env password works ...
assert provider.complete_password_login(
username="admin", password="env-pw"
)
# ... and the config password no longer does.
with pytest.raises(InvalidCredentialsError):
provider.complete_password_login(username="admin", password="config-pw")
def test_explicit_secret_makes_sessions_portable(self, basic, monkeypatch):
# Two providers built from the SAME explicit secret accept each
# other's tokens (the restart-/multi-worker-survival contract).
shared = secrets.token_bytes(32).hex()
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_SECRET", shared)
ctx1, ctx2 = MagicMock(), MagicMock()
basic.register(ctx1)
basic.register(ctx2)
p1 = ctx1.register_dashboard_auth_provider.call_args.args[0]
p2 = ctx2.register_dashboard_auth_provider.call_args.args[0]
s = p1.complete_password_login(username="admin", password="hunter2")
assert p2.verify_session(access_token=s.access_token) is not None
@@ -0,0 +1,189 @@
"""Tests for the DrainSecretProvider plugin (non-interactive bearer secret).
Task 2.0b. Loads the bundled drain plugin module directly and exercises:
* the entropy gate (assess_secret_strength) — fail-closed on weak secrets,
* constant-time verify_token returning a scoped TokenPrincipal,
* the register(ctx) entry point's env/config resolution, skip reasons, and
token-route registration.
"""
from __future__ import annotations
import secrets
from unittest.mock import MagicMock
import pytest
import plugins.dashboard_auth.drain as drain_plugin
from hermes_cli.dashboard_auth import TokenPrincipal, assert_protocol_compliance
from hermes_cli.dashboard_auth import token_auth
@pytest.fixture(scope="module")
def drain():
return drain_plugin
@pytest.fixture(autouse=True)
def _clean_env_and_routes(monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_DRAIN_SECRET", raising=False)
token_auth.clear_token_routes()
yield
token_auth.clear_token_routes()
def _strong_secret() -> str:
# token_urlsafe(32) → 43 url-safe-b64 chars ≈ 256 bits.
return secrets.token_urlsafe(32)
# ---------------------------------------------------------------------------
# Entropy gate
# ---------------------------------------------------------------------------
class TestEntropyGate:
def test_strong_secret_passes(self, drain):
assert drain.assess_secret_strength(_strong_secret()) is None
def test_empty_rejected(self, drain):
assert drain.assess_secret_strength("") is not None
def test_too_short_rejected(self, drain):
# 42 chars — one under the 43-char bar.
assert drain.assess_secret_strength("a1B2c3" * 7) is not None
def test_long_but_repeated_rejected(self, drain):
# 60 chars, one distinct character → low distinct count + low entropy.
assert drain.assess_secret_strength("a" * 60) is not None
def test_long_but_few_distinct_rejected(self, drain):
# 60 chars cycling through only 4 distinct characters.
assert drain.assess_secret_strength("abcd" * 15) is not None
def test_custom_min_chars_enforced(self, drain):
s = _strong_secret() # 43 chars
assert drain.assess_secret_strength(s, min_chars=999) is not None
# ---------------------------------------------------------------------------
# Provider behaviour
# ---------------------------------------------------------------------------
class TestProvider:
def test_protocol_compliance(self, drain):
assert_protocol_compliance(drain.DrainSecretProvider)
def test_supports_token_flag(self, drain):
p = drain.DrainSecretProvider(secret=_strong_secret())
assert p.supports_token is True
def test_is_non_interactive(self, drain):
# Excluded from interactive surfaces via list_session_providers().
p = drain.DrainSecretProvider(secret=_strong_secret())
assert p.supports_session is False
def test_verify_token_accepts_matching_secret(self, drain):
s = _strong_secret()
p = drain.DrainSecretProvider(secret=s, scope="drain")
principal = p.verify_token(token=s)
assert isinstance(principal, TokenPrincipal)
assert principal.principal == "drain-control"
assert principal.provider == "drain-secret"
assert principal.scopes == ("drain",)
def test_verify_token_rejects_wrong_secret(self, drain):
p = drain.DrainSecretProvider(secret=_strong_secret())
assert p.verify_token(token=_strong_secret()) is None
def test_verify_token_rejects_empty(self, drain):
p = drain.DrainSecretProvider(secret=_strong_secret())
assert p.verify_token(token="") is None
def test_custom_scope_attached(self, drain):
s = _strong_secret()
p = drain.DrainSecretProvider(secret=s, scope="lifecycle")
assert p.verify_token(token=s).scopes == ("lifecycle",)
def test_construction_rejects_weak_secret(self, drain):
with pytest.raises(ValueError):
drain.DrainSecretProvider(secret="weak")
def test_verify_session_returns_none_not_raises(self, drain):
# Stacks harmlessly in the cookie-verify loop.
p = drain.DrainSecretProvider(secret=_strong_secret())
assert p.verify_session(access_token="anything") is None
def test_interactive_methods_raise(self, drain):
p = drain.DrainSecretProvider(secret=_strong_secret())
with pytest.raises(NotImplementedError):
p.start_login(redirect_uri="r")
with pytest.raises(NotImplementedError):
p.complete_login(code="c", state="s", code_verifier="v", redirect_uri="r")
with pytest.raises(NotImplementedError):
p.refresh_session(refresh_token="r")
# ---------------------------------------------------------------------------
# register() entry point
# ---------------------------------------------------------------------------
class TestRegister:
def test_skips_when_no_secret(self, drain, monkeypatch):
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
ctx = MagicMock()
drain.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "HERMES_DASHBOARD_DRAIN_SECRET" in drain.LAST_SKIP_REASON
assert not token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
def test_skips_and_fails_closed_on_weak_secret(self, drain, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", "tooweak")
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
ctx = MagicMock()
drain.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "rejected" in drain.LAST_SKIP_REASON
# fail-closed: the route is NOT token-authable, so it stays gated.
assert not token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
def test_registers_with_strong_env_secret(self, drain, monkeypatch):
s = _strong_secret()
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
ctx = MagicMock()
drain.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert isinstance(provider, drain.DrainSecretProvider)
assert provider.verify_token(token=s) is not None
assert drain.LAST_SKIP_REASON == ""
# The drain endpoint is now token-authable.
assert token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
def test_config_scope_applied(self, drain, monkeypatch):
s = _strong_secret()
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
monkeypatch.setattr(
drain, "_load_config_drain_auth_section", lambda: {"scope": "lifecycle"}
)
ctx = MagicMock()
drain.register(ctx)
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert provider.verify_token(token=s).scopes == ("lifecycle",)
def test_config_min_secret_chars_can_reject_otherwise_ok_secret(
self, drain, monkeypatch
):
s = _strong_secret() # 43 chars — fine by default, too short at 999
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
monkeypatch.setattr(
drain,
"_load_config_drain_auth_section",
lambda: {"min_secret_chars": 999},
)
ctx = MagicMock()
drain.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "rejected" in drain.LAST_SKIP_REASON
@@ -0,0 +1,848 @@
"""Tests for the bundled Nous dashboard-auth plugin.
Covers four shapes from Phase 4 of ``.hermes/plans/2026-05-21-dashboard-oauth-auth.md``:
1. Plugin entry-point registration gating (env var checks).
2. ``start_login`` shape (PKCE/state, authorize URL parameters).
3. ``complete_login`` httpx-mocked happy path + error mapping.
4. ``verify_session`` JWT verification — RSA keypair, audience/issuer pinning,
``agent_instance_id`` cross-check, ``oauth_contract_version`` tolerance.
Also exercises ``revoke_session`` (no-op) and ``refresh_session``
(unconditional ``RefreshExpiredError``).
All HTTP is mocked: nothing in this file talks to a real Portal.
"""
from __future__ import annotations
import base64
import hashlib
import json
import time
import urllib.parse
from typing import Any, Dict
from unittest.mock import MagicMock, patch
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import plugins.dashboard_auth.nous as nous_plugin
from hermes_cli.dashboard_auth import (
InvalidCodeError,
LoginStart,
ProviderError,
RefreshExpiredError,
Session,
assert_protocol_compliance,
)
# ---------------------------------------------------------------------------
# RSA keypair fixture (module-scope — keygen is slow)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def rsa_keypair() -> Dict[str, Any]:
"""Generate an RS256 keypair + matching JWK for verify_session tests."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_numbers = key.public_key().public_numbers()
def _b64url_uint(n: int) -> str:
length = (n.bit_length() + 7) // 8
return (
base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
)
jwk = {
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "test-key-1",
"n": _b64url_uint(public_numbers.n),
"e": _b64url_uint(public_numbers.e),
}
return {"private_pem": private_pem, "jwk": jwk, "kid": jwk["kid"]}
# ---------------------------------------------------------------------------
# Token-mint helper
# ---------------------------------------------------------------------------
def _mint_token(
rsa_keypair: Dict[str, Any],
*,
iss: str = "https://portal.example.com",
aud: str = "agent:inst123",
sub: str = "usr_abc",
agent_instance_id: str | None = "inst123",
oauth_contract_version: Any = 1,
org_id: str | None = "org_xyz",
scope: str = "agent_dashboard:access",
ttl_seconds: int = 900,
extra_claims: Dict[str, Any] | None = None,
) -> str:
now = int(time.time())
claims = {
"iss": iss,
"aud": aud,
"sub": sub,
"iat": now,
"exp": now + ttl_seconds,
"scope": scope,
}
if agent_instance_id is not None:
claims["agent_instance_id"] = agent_instance_id
if oauth_contract_version is not None:
claims["oauth_contract_version"] = oauth_contract_version
if org_id is not None:
claims["org_id"] = org_id
if extra_claims:
claims.update(extra_claims)
return jwt.encode(
claims,
rsa_keypair["private_pem"],
algorithm="RS256",
headers={"kid": rsa_keypair["kid"]},
)
def _patched_jwks(provider: nous_plugin.NousDashboardAuthProvider, rsa_keypair):
"""Patch the provider's JWKS client to return our fixture key."""
fake_key = MagicMock()
fake_key.key = serialization.load_pem_private_key(
rsa_keypair["private_pem"].encode(), password=None
).public_key()
fake_client = MagicMock()
fake_client.get_signing_key_from_jwt.return_value = fake_key
provider._jwks_client = fake_client
# ---------------------------------------------------------------------------
# Provider construction
# ---------------------------------------------------------------------------
class TestConstruction:
def test_protocol_compliance(self):
assert_protocol_compliance(nous_plugin.NousDashboardAuthProvider)
def test_name_and_display(self):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst1", portal_url="https://portal.example.com"
)
assert p.name == "nous"
assert p.display_name == "Nous Research"
def test_extracts_agent_instance_id(self):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:abc-123", portal_url="https://portal.example.com"
)
assert p._agent_instance_id == "abc-123"
def test_strips_trailing_slash_from_portal_url(self):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:x", portal_url="https://portal.example.com/"
)
assert p._portal_url == "https://portal.example.com"
def test_rejects_malformed_client_id(self):
with pytest.raises(ValueError, match="agent:"):
nous_plugin.NousDashboardAuthProvider(
client_id="hermes-dashboard", portal_url="https://x"
)
# ---------------------------------------------------------------------------
# Plugin entry point: env-gated registration
# ---------------------------------------------------------------------------
class TestPluginRegister:
def test_skips_when_client_id_missing(self, monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
# Skip reason is surfaced for the gate's fail-closed message.
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
def test_registers_with_default_portal_url_when_only_client_id_set(
self, monkeypatch
):
"""Phase 7 follow-up: HERMES_DASHBOARD_PORTAL_URL is optional —
defaults to the production Nous Portal. The user shouldn't have
to set it for the common production deployment path."""
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert isinstance(registered, nous_plugin.NousDashboardAuthProvider)
assert registered._portal_url == "https://portal.nousresearch.com"
# Skip reason cleared on successful registration.
assert nous_plugin.LAST_SKIP_REASON == ""
def test_skips_when_client_id_malformed(self, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "hermes-dashboard")
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "https://p.example")
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
# Skip reason names the offending value + contract shape.
assert "agent:" in nous_plugin.LAST_SKIP_REASON
assert "hermes-dashboard" in nous_plugin.LAST_SKIP_REASON
def test_registers_with_explicit_portal_url(self, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "https://p.example")
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_id == "agent:inst1"
assert registered._portal_url == "https://p.example"
def test_strips_whitespace_from_env_vars(self, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", " agent:x ")
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", " https://p.example ")
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
def test_empty_portal_url_env_uses_default(self, monkeypatch):
"""Explicit empty string still falls back to the production
default — same handling as 'unset' so an empty Fly secret can't
accidentally point the dashboard at nowhere."""
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "")
ctx = MagicMock()
nous_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._portal_url == "https://portal.nousresearch.com"
# ---------------------------------------------------------------------------
# Plugin entry point: config.yaml + env-override precedence
# ---------------------------------------------------------------------------
class TestConfigYamlSource:
"""``dashboard.oauth.{client_id,portal_url}`` in ``config.yaml`` is the
canonical surface for these settings. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``
and ``HERMES_DASHBOARD_PORTAL_URL`` are operator overrides that win when
set — this is the contract Fly.io's platform-secret injection relies on,
and the contract that lets local devs experiment without setting env
vars.
Each test pins exactly one tier of the precedence chain so a regression
that flips the order is caught:
env (when truthy) > config.yaml (when truthy) > plugin default
"""
@pytest.fixture
def patch_config(self, monkeypatch):
"""Yield a callable that replaces ``hermes_cli.config.load_config``
with a stub returning the given dict. Tests pass the intended
``dashboard.oauth`` block; the stub returns the wrapping structure."""
def _set(oauth_block: Dict[str, Any] | None) -> None:
cfg = {}
if oauth_block is not None:
cfg = {"dashboard": {"oauth": oauth_block}}
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: cfg
)
return _set
def test_config_yaml_only_client_id_registers(self, patch_config, monkeypatch):
"""No env var, only config.yaml — plugin reads from config and
registers successfully. This is the path Teknium's review pushed
for (".env is for secrets only")."""
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
patch_config({"client_id": "agent:from-config"})
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_id == "agent:from-config"
# Defaults to production portal URL when neither config nor env
# specifies one.
assert registered._portal_url == "https://portal.nousresearch.com"
def test_config_yaml_client_id_and_portal_url(self, patch_config, monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
patch_config({
"client_id": "agent:from-config",
"portal_url": "https://staging.portal.example",
})
ctx = MagicMock()
nous_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_id == "agent:from-config"
assert registered._portal_url == "https://staging.portal.example"
def test_env_overrides_config_client_id(self, patch_config, monkeypatch):
"""Env wins. Critical for Fly.io: the Portal injects
HERMES_DASHBOARD_OAUTH_CLIENT_ID at deploy time and we MUST
honour it even if a stale config.yaml ships in the image."""
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:from-env")
patch_config({"client_id": "agent:from-config"})
ctx = MagicMock()
nous_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_id == "agent:from-env", (
"env var must override config.yaml — Fly secret injection "
"depends on this precedence"
)
def test_env_overrides_config_portal_url(self, patch_config, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:x")
monkeypatch.setenv(
"HERMES_DASHBOARD_PORTAL_URL", "https://env.portal.example",
)
patch_config({
"client_id": "agent:x",
"portal_url": "https://config.portal.example",
})
ctx = MagicMock()
nous_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._portal_url == "https://env.portal.example"
def test_empty_env_string_does_not_shadow_config(
self, patch_config, monkeypatch
):
"""``HERMES_DASHBOARD_OAUTH_CLIENT_ID=`` (set but empty) is
common in CI/Fly when a secret is provisioned-but-not-populated.
It MUST NOT shadow a valid config.yaml value with an empty
string — operators would lose the gate."""
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "")
patch_config({"client_id": "agent:from-config"})
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_id == "agent:from-config"
def test_neither_source_skips_with_helpful_reason(
self, patch_config, monkeypatch
):
"""Neither env nor config.yaml set — skip with a reason that
mentions BOTH surfaces so operators don't guess wrong about
which one to populate."""
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
patch_config(None)
ctx = MagicMock()
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
# Old behaviour: skip reason mentions the env var.
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
# New behaviour: skip reason ALSO mentions the config.yaml path
# so the user knows it's a valid alternative.
assert "dashboard.oauth.client_id" in nous_plugin.LAST_SKIP_REASON, (
f"skip reason omits the config.yaml surface — operators "
f"won't know it exists. got: {nous_plugin.LAST_SKIP_REASON!r}"
)
def test_config_yaml_load_failure_falls_through_cleanly(
self, monkeypatch
):
"""If load_config() raises (e.g. malformed YAML, IOError), the
plugin must not crash — it falls through to the env-only path
and either succeeds (if env is set) or surfaces the standard
'not set' skip reason."""
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
def _broken_load():
raise OSError("config.yaml not readable")
monkeypatch.setattr(
"hermes_cli.config.load_config", _broken_load
)
ctx = MagicMock()
# Must not raise.
nous_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
def test_config_yaml_with_non_dict_oauth_section(
self, monkeypatch
):
"""cfg_get handles 'config has a string where a section was
expected' robustly. Verify the plugin inherits that resilience
so a malformed user config doesn't crash startup."""
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"dashboard": {"oauth": "wrong type"}},
)
ctx = MagicMock()
nous_plugin.register(ctx)
# Falls through to the no-env-and-no-config path.
ctx.register_dashboard_auth_provider.assert_not_called()
# ---------------------------------------------------------------------------
# start_login
# ---------------------------------------------------------------------------
class TestStartLogin:
@pytest.fixture
def provider(self):
return nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst1", portal_url="https://portal.example.com"
)
def test_returns_login_start(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
assert isinstance(result, LoginStart)
def test_redirect_url_targets_portal_authorize(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
assert result.redirect_url.startswith(
"https://portal.example.com/oauth/authorize?"
)
def test_authorize_url_has_required_params(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
parsed = urllib.parse.urlparse(result.redirect_url)
params = dict(urllib.parse.parse_qsl(parsed.query))
assert params["response_type"] == "code"
assert params["client_id"] == "agent:inst1"
assert params["redirect_uri"] == "https://hermes.fly.dev/auth/callback"
assert params["scope"] == "agent_dashboard:access"
assert params["code_challenge_method"] == "S256"
assert "state" in params
assert "code_challenge" in params
def test_code_verifier_in_cookie_payload_43_to_128_chars(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
assert "hermes_session_pkce" in result.cookie_payload
pkce = result.cookie_payload["hermes_session_pkce"]
# Shape: ``state=…;verifier=…`` (matches stub-provider convention so
# the auth-route layer's parser works uniformly across providers).
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
verifier = parts["verifier"]
# RFC 7636 §4.1
assert 43 <= len(verifier) <= 128
def test_state_in_cookie_payload_matches_url_param(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
parsed = urllib.parse.urlparse(result.redirect_url)
params = dict(urllib.parse.parse_qsl(parsed.query))
pkce = result.cookie_payload["hermes_session_pkce"]
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
assert parts["state"] == params["state"]
def test_code_challenge_is_s256_of_verifier(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
parsed = urllib.parse.urlparse(result.redirect_url)
params = dict(urllib.parse.parse_qsl(parsed.query))
pkce = result.cookie_payload["hermes_session_pkce"]
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
verifier = parts["verifier"]
expected_challenge = (
base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode("ascii")).digest()
)
.rstrip(b"=")
.decode()
)
assert params["code_challenge"] == expected_challenge
def test_two_calls_produce_different_state_and_verifier(self, provider):
a = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
b = provider.start_login(
redirect_uri="https://hermes.fly.dev/auth/callback"
)
assert a.cookie_payload["hermes_session_pkce"] != b.cookie_payload[
"hermes_session_pkce"
]
def test_rejects_non_http_scheme(self, provider):
with pytest.raises(ProviderError, match="http"):
provider.start_login(redirect_uri="ftp://x/auth/callback")
def test_allows_http_with_arbitrary_host(self, provider):
# http:// is permitted for any host now, not just localhost — the
# Portal-side check is authoritative on which redirect_uris are
# accepted; this client-side fast-fail must not reject self-hosted
# dashboards reached over plain HTTP (LAN IPs, internal hostnames,
# TLS-terminating reverse proxies). Should not raise.
provider.start_login(redirect_uri="http://hermes.fly.dev/auth/callback")
provider.start_login(redirect_uri="http://192.168.1.50:8080/auth/callback")
provider.start_login(redirect_uri="http://my-internal-host/auth/callback")
def test_allows_http_localhost(self, provider):
# Should not raise.
provider.start_login(redirect_uri="http://localhost:8080/auth/callback")
provider.start_login(redirect_uri="http://127.0.0.1:8080/auth/callback")
def test_rejects_wrong_callback_path(self, provider):
with pytest.raises(ProviderError, match="/auth/callback"):
provider.start_login(redirect_uri="https://x.example/oauth/cb")
# ---------------------------------------------------------------------------
# complete_login (httpx mocked)
# ---------------------------------------------------------------------------
class TestCompleteLogin:
@pytest.fixture
def provider(self, rsa_keypair):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst123", portal_url="https://portal.example.com"
)
_patched_jwks(p, rsa_keypair)
return p
def _mock_post(self, status_code: int, body: Any, *, ctype: str = "application/json"):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
if isinstance(body, dict):
resp.text = json.dumps(body)
resp.json = MagicMock(return_value=body)
else:
resp.text = body
# _parse_json_body bails on non-application/json before .json()
# is called, but be safe for callers that pass a non-dict body
# with ctype=application/json.
resp.json = MagicMock(side_effect=ValueError("not json"))
resp.headers = {"content-type": ctype}
return resp
def test_happy_path_returns_session(self, provider, rsa_keypair):
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt_initial_value",
},
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="abc",
state="state-val",
code_verifier="vfy",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
assert isinstance(session, Session)
assert session.user_id == "usr_abc"
assert session.provider == "nous"
assert session.access_token == access_token
# The dashboard auth-code grant now issues a refresh token (NAS #293);
# complete_login must surface it so the middleware persists it.
assert session.refresh_token == "rt_initial_value"
assert session.org_id == "org_xyz"
assert session.email == ""
assert session.display_name == ""
def test_happy_path_tolerates_missing_refresh_token(self, provider, rsa_keypair):
# If Portal omits refresh_token (older deploy), the session is still
# valid as access-token-only; refresh_token defaults to "".
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200, {"access_token": access_token, "token_type": "Bearer"}
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="abc",
state="state-val",
code_verifier="vfy",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
assert session.refresh_token == ""
def test_400_raises_invalid_code(self, provider):
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(InvalidCodeError, match="invalid_grant"):
provider.complete_login(
code="bad", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
def test_500_raises_provider_error(self, provider):
mock_resp = self._mock_post(500, "internal server error", ctype="text/plain")
mock_resp.text = "internal server error"
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError, match="500"):
provider.complete_login(
code="x", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
def test_missing_access_token_raises(self, provider):
mock_resp = self._mock_post(200, {"token_type": "Bearer"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError, match="access_token"):
provider.complete_login(
code="x", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
def test_unexpected_token_type_raises(self, provider, rsa_keypair):
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200, {"access_token": access_token, "token_type": "DPoP"}
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError, match="token_type"):
provider.complete_login(
code="x", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
def test_network_error_raises_provider_error(self, provider):
with patch(
"plugins.dashboard_auth.nous.httpx.post",
side_effect=httpx.ConnectError("conn refused"),
):
with pytest.raises(ProviderError, match="unreachable"):
provider.complete_login(
code="x", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
def test_captures_refresh_token_if_present_forward_compat(
self, provider, rsa_keypair
):
"""Forward-compat: contract V1 doesn't issue, but if a future Portal
does, we should preserve it in the Session for later use."""
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt-opaque",
},
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="x", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
assert session.refresh_token == "rt-opaque"
# ---------------------------------------------------------------------------
# verify_session
# ---------------------------------------------------------------------------
class TestVerifySession:
@pytest.fixture
def provider(self, rsa_keypair):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst123", portal_url="https://portal.example.com"
)
_patched_jwks(p, rsa_keypair)
return p
def test_happy_path_returns_session(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair)
session = provider.verify_session(access_token=token)
assert session is not None
assert session.user_id == "usr_abc"
assert session.org_id == "org_xyz"
def test_expired_token_returns_none(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair, ttl_seconds=-1)
assert provider.verify_session(access_token=token) is None
def test_wrong_audience_raises_provider_error(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair, aud="agent:other-instance")
with pytest.raises(ProviderError, match="verification failed"):
provider.verify_session(access_token=token)
def test_wrong_issuer_raises_provider_error(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair, iss="https://evil.example")
with pytest.raises(ProviderError, match="verification failed"):
provider.verify_session(access_token=token)
def test_verification_failure_message_surfaces_token_claims(
self, provider, rsa_keypair
):
"""Operators need to see the actual iss/aud the token carries to debug
config drift between HERMES_DASHBOARD_PORTAL_URL/CLIENT_ID and Portal."""
token = _mint_token(rsa_keypair, iss="https://evil.example")
with pytest.raises(ProviderError) as excinfo:
provider.verify_session(access_token=token)
msg = str(excinfo.value)
# Both the observed (token) and expected (configured) values appear.
assert "'https://evil.example'" in msg
assert "'https://portal.example.com'" in msg # configured portal URL
def test_missing_sub_raises(self, provider, rsa_keypair):
# PyJWT's "require" set includes sub, so this surfaces as
# InvalidTokenError → ProviderError before we ever touch _session_from_claims.
token = _mint_token(rsa_keypair, sub="")
# Empty sub still encodes successfully; PyJWT's require check only
# asserts presence. Our own _session_from_claims rejects empty.
with pytest.raises(ProviderError, match="sub"):
provider.verify_session(access_token=token)
def test_agent_instance_id_mismatch_rejected(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair, agent_instance_id="some-other-id")
with pytest.raises(ProviderError, match="agent_instance_id mismatch"):
provider.verify_session(access_token=token)
def test_agent_instance_id_missing_is_tolerated(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair, agent_instance_id=None)
session = provider.verify_session(access_token=token)
assert session is not None
def test_contract_version_missing_warns_but_succeeds(
self, provider, rsa_keypair, caplog
):
import logging
token = _mint_token(rsa_keypair, oauth_contract_version=None)
with caplog.at_level(logging.WARNING, logger="plugins.dashboard_auth.nous"):
session = provider.verify_session(access_token=token)
assert session is not None
assert any(
"oauth_contract_version" in r.message for r in caplog.records
)
def test_contract_version_mismatch_rejected(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair, oauth_contract_version=2)
with pytest.raises(ProviderError, match="oauth_contract_version"):
provider.verify_session(access_token=token)
def test_jwks_unreachable_raises_provider_error(self, provider, rsa_keypair):
token = _mint_token(rsa_keypair)
# Replace the patched client so it raises.
bad_client = MagicMock()
bad_client.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError(
"fetch failed"
)
provider._jwks_client = bad_client
with pytest.raises(ProviderError, match="JWKS"):
provider.verify_session(access_token=token)
# ---------------------------------------------------------------------------
# refresh_session + revoke_session
# ---------------------------------------------------------------------------
class TestRefreshAndRevoke:
@pytest.fixture
def provider(self, rsa_keypair):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst123", portal_url="https://portal.example.com"
)
_patched_jwks(p, rsa_keypair)
return p
def _mock_post(self, status_code, body, *, ctype="application/json"):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
if isinstance(body, dict):
resp.text = json.dumps(body)
resp.json = MagicMock(return_value=body)
else:
resp.text = body
resp.json = MagicMock(side_effect=ValueError("not json"))
resp.headers = {"content-type": ctype}
return resp
def test_refresh_happy_path_returns_rotated_session(self, provider, rsa_keypair):
# Portal returns a fresh access token AND a rotated refresh token.
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt_rotated_value",
},
)
with patch(
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
) as mock_post:
session = provider.refresh_session(refresh_token="rt_old_value")
assert isinstance(session, Session)
assert session.access_token == access_token
# The ROTATED refresh token must be surfaced so the middleware can
# persist it back to the cookie.
assert session.refresh_token == "rt_rotated_value"
assert session.provider == "nous"
# Posts grant_type=refresh_token with the RT in BOTH the body (Portal's
# schema requires it there) and the X-Refresh-Token header (log
# redaction). Verified against the live preview deploy.
_, kwargs = mock_post.call_args
assert kwargs["data"]["grant_type"] == "refresh_token"
assert kwargs["data"]["client_id"] == "agent:inst123"
assert kwargs["data"]["refresh_token"] == "rt_old_value"
assert kwargs["headers"]["x-nous-refresh-token"] == "rt_old_value"
def test_refresh_400_raises_refresh_expired(self, provider):
# Expired / revoked / reuse-detected RT → Portal 400 → force re-login.
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(RefreshExpiredError, match="invalid_grant"):
provider.refresh_session(refresh_token="rt_dead")
def test_refresh_empty_token_raises_refresh_expired_without_network(self, provider):
# No RT present — fail fast as a dead session, never hit the network.
with patch("plugins.dashboard_auth.nous.httpx.post") as mock_post:
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="")
mock_post.assert_not_called()
def test_refresh_network_error_raises_provider_error(self, provider):
with patch(
"plugins.dashboard_auth.nous.httpx.post",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(ProviderError, match="unreachable"):
provider.refresh_session(refresh_token="rt_x")
def test_refresh_500_raises_provider_error(self, provider):
mock_resp = self._mock_post(500, "oops", ctype="text/plain")
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError):
provider.refresh_session(refresh_token="rt_x")
def test_revoke_is_noop(self, provider):
# Must not raise; returns None implicitly.
assert provider.revoke_session(refresh_token="anything") is None
assert provider.revoke_session(refresh_token="") is None
File diff suppressed because it is too large Load Diff
View File
@@ -0,0 +1,300 @@
"""Behavior-parity check for the image-gen FAL plugin migration (#26241).
Spawns one subprocess per (version, scenario) cell — pinned to either
``origin/main`` (legacy in-tree FAL fall-through + ``configured == "fal"``
skip in ``_dispatch_to_plugin_provider``) or this PR's worktree (FAL is
itself a plugin and the dispatcher routes every set provider through
the registry). Each subprocess clears all FAL-related env vars + writes
a ``config.yaml``, then asks the dispatcher how it would route an
``image_generate`` call. The emitted shape tuple is
``{dispatch_kind, provider_name, model}``:
* ``dispatch_kind`` ∈ ``{"legacy_fal", "plugin", "error", None}`` —
whether the call would go straight to the in-tree pipeline,
through ``_dispatch_to_plugin_provider``, raise an explicit
provider-not-registered error, or fall through silently.
* ``provider_name`` — when ``dispatch_kind == "plugin"``, the
resolved provider name. ``None`` otherwise.
* ``model`` — the resolved FAL model id when applicable.
The parent process diffs the shapes per scenario. A diff means the
migration introduced an observable behaviour change vs origin/main —
likely a real regression for users on the existing config keys.
Run from the PR worktree:
python tests/plugins/image_gen/check_parity_vs_main.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
# Pin one path to current main, one to the PR worktree.
# ``REPO_ROOT`` is ``.../.worktrees/<name>``; the main checkout lives
# two levels up. When running directly from a regular clone (no
# worktree), ``MAIN_DIR`` falls back to a sibling ``hermes-agent-main``
# checkout if one exists.
def _resolve_main_dir() -> Path:
candidate = REPO_ROOT.parent.parent
if (candidate / "tools" / "image_generation_tool.py").exists() and candidate != REPO_ROOT:
return candidate
sibling = REPO_ROOT.parent / "hermes-agent-main"
if (sibling / "tools" / "image_generation_tool.py").exists():
return sibling
return REPO_ROOT
MAIN_DIR = _resolve_main_dir()
PR_DIR = REPO_ROOT
assert (PR_DIR / "tools" / "image_generation_tool.py").exists(), (
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
)
SUBPROCESS_SCRIPT = r"""
import json, os, sys, tempfile
sys.path.insert(0, sys.argv[1])
# Isolated HERMES_HOME so the config write is hermetic.
home = tempfile.mkdtemp()
os.environ["HERMES_HOME"] = home
# Clear FAL-related env so dispatch decisions are config-driven.
for k in (
"FAL_KEY", "FAL_QUEUE_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
"FAL_IMAGE_MODEL",
):
os.environ.pop(k, None)
scenario_env = json.loads(sys.argv[2])
os.environ.update(scenario_env)
config_yaml = sys.argv[3]
config_path = os.path.join(home, "config.yaml")
with open(config_path, "w") as f:
f.write(config_yaml)
# Fresh import — must not have anything cached.
for name in list(sys.modules):
if (name.startswith("tools.")
or name.startswith("agent.")
or name.startswith("plugins.")
or name.startswith("hermes_cli.")):
sys.modules.pop(name, None)
import tools.image_generation_tool as image_tool
dispatch_kind = None
provider_name = None
model = None
error_text = None
try:
raw = image_tool._dispatch_to_plugin_provider("ping", "landscape")
if raw is None:
dispatch_kind = "legacy_fal"
else:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
if parsed.get("error_type") == "provider_not_registered":
dispatch_kind = "error"
error_text = parsed.get("error")
else:
dispatch_kind = "plugin"
provider_name = parsed.get("provider")
model = parsed.get("model")
else:
dispatch_kind = "unknown_payload"
if model is None:
# _resolve_fal_model still returns the active FAL model id even
# when dispatch goes to a non-FAL plugin — used for the diff
# only when applicable.
try:
model_id, _meta = image_tool._resolve_fal_model()
if dispatch_kind == "legacy_fal":
model = model_id
except Exception:
pass
except Exception as exc:
dispatch_kind = "exception"
error_text = repr(exc)
shape = {
"dispatch_kind": dispatch_kind,
"provider_name": provider_name,
"model": model,
"error_present": error_text is not None,
}
print(json.dumps(shape))
"""
SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
# (label, config.yaml body, extra env vars)
("no-config-no-env", "", {}),
(
"explicit-fal-no-creds",
"image_gen:\n provider: fal\n",
{},
),
(
"explicit-fal-with-creds",
"image_gen:\n provider: fal\n",
{"FAL_KEY": "test-key"},
),
(
"explicit-fal-with-model",
"image_gen:\n provider: fal\n model: fal-ai/flux-2-pro\n",
{"FAL_KEY": "test-key"},
),
(
"explicit-typo-provider",
"image_gen:\n provider: not-a-real-backend\n",
{"FAL_KEY": "test-key"},
),
(
"managed-gateway-only",
"",
{
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
},
),
]
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
venv_python = repo_path / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = Path("python3")
out = subprocess.run(
[
str(venv_python),
"-c",
SUBPROCESS_SCRIPT,
str(repo_path),
json.dumps(env),
config_yaml,
],
capture_output=True,
text=True,
timeout=60,
)
if out.returncode != 0:
return {
"error": "subprocess failed",
"stdout": out.stdout[-500:],
"stderr": out.stderr[-500:],
}
try:
return json.loads(out.stdout.strip().splitlines()[-1])
except Exception as exc:
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
def _reduce(shape: dict) -> dict:
"""Reduce to the parts that matter for user-visible parity.
On origin/main, ``explicit-fal-*`` scenarios short-circuit to
``legacy_fal`` because of the ``configured == "fal"`` skip. On the
PR, those same scenarios route through the plugin and emit
``dispatch_kind == "plugin"`` with ``provider_name == "fal"``.
Both shapes are functionally equivalent — the plugin's ``generate()``
re-enters the same in-tree pipeline via ``_it`` indirection — but
we want the diff to be visible so reviewers can sign off on the
intentional behaviour delta.
"""
return {
"dispatch_kind": shape.get("dispatch_kind"),
"provider_name": shape.get("provider_name"),
"model": shape.get("model"),
"error_present": shape.get("error_present"),
}
def main() -> int:
print(f"main: {MAIN_DIR}")
print(f"pr: {PR_DIR}")
print()
if MAIN_DIR == PR_DIR:
print(
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
" Set up a sibling 'hermes-agent-main' checkout pinned to "
"origin/main to get real parity coverage."
)
print()
failures: list[str] = []
errors: list[str] = []
intentional_diffs: list[tuple[str, dict, dict]] = []
for label, config_yaml, env in SCENARIOS:
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
if "error" in main_shape or "error" in pr_shape:
print(f" [ERR ] {label}: subprocess failed")
print(f" main: {main_shape}")
print(f" pr: {pr_shape}")
errors.append(label)
continue
main_reduced = _reduce(main_shape)
pr_reduced = _reduce(pr_shape)
if main_reduced == pr_reduced:
print(f" [OK] {label}: {main_reduced}")
continue
# On main, "explicit-fal-*" returns legacy_fal; on PR, plugin
# dispatch. That's the only acceptable diff — flag everything
# else as a regression.
legacy_to_plugin_fal = (
main_reduced.get("dispatch_kind") == "legacy_fal"
and pr_reduced.get("dispatch_kind") == "plugin"
and pr_reduced.get("provider_name") == "fal"
)
if legacy_to_plugin_fal:
print(f" [DIFF] {label}: legacy_fal → plugin (fal) — expected")
intentional_diffs.append((label, main_reduced, pr_reduced))
else:
print(f" [FAIL] {label}")
print(f" main: {main_reduced}")
print(f" pr: {pr_reduced}")
failures.append(label)
print()
if errors:
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
for e in errors:
print(f" - {e}")
if failures:
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
for f in failures:
print(f" - {f}")
if intentional_diffs:
print(
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
f"legacy_fal → plugin dispatch for explicit FAL paths."
)
if failures or errors:
return 1
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Tests for the FAL.ai image generation plugin.
The plugin is a thin registration adapter — actual FAL pipeline logic
lives in ``tools.image_generation_tool`` and is exercised by
``tests/tools/test_image_generation.py``. These tests focus on:
* the ``ImageGenProvider`` ABC surface (name, models, schema)
* call-time indirection (``_it`` resolution at ``generate()`` time so
``monkeypatch.setattr(image_tool, ...)`` keeps working)
* response shape stamping (provider/prompt/aspect_ratio/model)
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# Provider surface
# ---------------------------------------------------------------------------
class TestFalImageGenProviderSurface:
def test_name(self):
from plugins.image_gen.fal import FalImageGenProvider
assert FalImageGenProvider().name == "fal"
def test_display_name(self):
from plugins.image_gen.fal import FalImageGenProvider
assert FalImageGenProvider().display_name == "FAL.ai"
def test_default_model_matches_legacy(self):
from plugins.image_gen.fal import FalImageGenProvider
from tools.image_generation_tool import DEFAULT_MODEL
assert FalImageGenProvider().default_model() == DEFAULT_MODEL
def test_list_models_uses_legacy_catalog(self):
from plugins.image_gen.fal import FalImageGenProvider
from tools.image_generation_tool import FAL_MODELS
provider = FalImageGenProvider()
models = provider.list_models()
ids = {m["id"] for m in models}
# Whatever FAL_MODELS ships, the provider mirrors verbatim.
assert ids == set(FAL_MODELS.keys())
# Spot-check the expected first-class fields are present.
for entry in models:
for field in ("id", "display", "speed", "strengths", "price"):
assert field in entry
def test_setup_schema_advertises_fal_key(self):
from plugins.image_gen.fal import FalImageGenProvider
schema = FalImageGenProvider().get_setup_schema()
assert schema["name"] == "FAL.ai"
assert schema["badge"] == "paid"
env_keys = {entry["key"] for entry in schema.get("env_vars", [])}
assert "FAL_KEY" in env_keys
class TestFalImageGenProviderAvailability:
def test_is_available_when_legacy_check_passes(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
monkeypatch.setattr(image_tool, "check_fal_api_key", lambda: True)
assert FalImageGenProvider().is_available() is True
def test_is_available_false_when_legacy_check_fails(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
monkeypatch.setattr(image_tool, "check_fal_api_key", lambda: False)
assert FalImageGenProvider().is_available() is False
def test_is_available_handles_legacy_exception(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
def _boom():
raise RuntimeError("config broke")
monkeypatch.setattr(image_tool, "check_fal_api_key", _boom)
# Picker must not propagate exceptions — show as "not available".
assert FalImageGenProvider().is_available() is False
# ---------------------------------------------------------------------------
# generate() — call-time indirection
# ---------------------------------------------------------------------------
class TestFalImageGenProviderGenerate:
def test_generate_delegates_to_legacy_image_generate_tool(self, monkeypatch):
"""Plugin must look up ``image_generate_tool`` at call time so
``monkeypatch.setattr(image_tool, "image_generate_tool", ...)``
takes effect."""
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
captured = {}
def fake_image_generate_tool(prompt, aspect_ratio, **kwargs):
captured["prompt"] = prompt
captured["aspect_ratio"] = aspect_ratio
captured["kwargs"] = kwargs
return json.dumps({"success": True, "image": "https://fake/image.png"})
monkeypatch.setattr(image_tool, "image_generate_tool", fake_image_generate_tool)
monkeypatch.setattr(image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2/klein/9b", {}))
result = FalImageGenProvider().generate(
"a serene mountain landscape",
aspect_ratio="square",
seed=42,
)
assert captured["prompt"] == "a serene mountain landscape"
assert captured["aspect_ratio"] == "square"
assert captured["kwargs"] == {"seed": 42}
assert result["success"] is True
assert result["image"] == "https://fake/image.png"
# Stamped fields for the unified response shape
assert result["provider"] == "fal"
assert result["prompt"] == "a serene mountain landscape"
assert result["aspect_ratio"] == "square"
assert result["model"] == "fal-ai/flux-2/klein/9b"
def test_generate_invalid_aspect_ratio_is_coerced(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
seen_aspect = {}
def fake(prompt, aspect_ratio, **kwargs):
seen_aspect["v"] = aspect_ratio
return json.dumps({"success": True, "image": "x"})
monkeypatch.setattr(image_tool, "image_generate_tool", fake)
monkeypatch.setattr(image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2/klein/9b", {}))
FalImageGenProvider().generate("p", aspect_ratio="not-a-real-ratio")
# ``resolve_aspect_ratio`` clamps to landscape.
assert seen_aspect["v"] == "landscape"
def test_generate_passthrough_drops_none_kwargs(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
seen = {}
def fake(prompt, aspect_ratio, **kwargs):
seen.update(kwargs)
return json.dumps({"success": True, "image": "x"})
monkeypatch.setattr(image_tool, "image_generate_tool", fake)
monkeypatch.setattr(image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2/klein/9b", {}))
FalImageGenProvider().generate(
"p",
aspect_ratio="landscape",
seed=None,
num_images=2,
guidance_scale=None,
)
# ``None`` values must not be forwarded — they'd override the
# model's defaults inside the legacy payload builder.
assert "seed" not in seen
assert "guidance_scale" not in seen
assert seen.get("num_images") == 2
def test_generate_catches_exception_from_legacy(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
def boom(*args, **kwargs):
raise RuntimeError("FAL endpoint exploded")
monkeypatch.setattr(image_tool, "image_generate_tool", boom)
result = FalImageGenProvider().generate("p")
assert result["success"] is False
assert "FAL image generation failed" in result["error"]
assert result["error_type"] == "RuntimeError"
assert result["provider"] == "fal"
def test_generate_invalid_json_response(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
monkeypatch.setattr(image_tool, "image_generate_tool", lambda **kw: "not-json")
monkeypatch.setattr(image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2/klein/9b", {}))
result = FalImageGenProvider().generate("p")
assert result["success"] is False
assert "Invalid JSON" in result["error"]
assert result["provider"] == "fal"
# ---------------------------------------------------------------------------
# Registry wiring
# ---------------------------------------------------------------------------
class TestFalImageGenPluginRegistration:
def test_register_wires_provider_into_registry(self):
from plugins.image_gen.fal import FalImageGenProvider, register
ctx = MagicMock()
register(ctx)
ctx.register_image_gen_provider.assert_called_once()
(registered,), _ = ctx.register_image_gen_provider.call_args
assert isinstance(registered, FalImageGenProvider)
@@ -0,0 +1,848 @@
#!/usr/bin/env python3
"""Tests for Krea image generation provider."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _fake_api_key(monkeypatch):
"""Ensure KREA_API_KEY is set for all tests."""
monkeypatch.setenv("KREA_API_KEY", "test-key-12345")
def _completed_job(url: str = "https://krea.cdn/img.png") -> dict:
return {
"job_id": "00000000-0000-0000-0000-000000000abc",
"status": "completed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:00:30Z",
"result": {"urls": [url]},
}
def _submit_response(job_id: str = "00000000-0000-0000-0000-000000000abc"):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"job_id": job_id,
"status": "queued",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": None,
"result": None,
}
return resp
def _poll_response(body: dict):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = body
return resp
# ---------------------------------------------------------------------------
# Provider class tests
# ---------------------------------------------------------------------------
class TestKreaImageGenProvider:
def test_name(self):
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().name == "krea"
def test_display_name(self):
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().display_name == "Krea"
def test_is_available_with_key(self, monkeypatch):
monkeypatch.setenv("KREA_API_KEY", "sk-test")
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().is_available() is True
def test_is_available_without_key(self, monkeypatch):
monkeypatch.delenv("KREA_API_KEY", raising=False)
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
# No direct key AND no managed gateway → unavailable.
monkeypatch.setattr(krea_mod, "_managed_krea_gateway_ready", lambda: False)
assert KreaImageGenProvider().is_available() is False
def test_is_available_via_managed_gateway_without_key(self, monkeypatch):
monkeypatch.delenv("KREA_API_KEY", raising=False)
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
# No direct key but the managed Nous gateway is ready → available.
monkeypatch.setattr(krea_mod, "_managed_krea_gateway_ready", lambda: True)
assert KreaImageGenProvider().is_available() is True
def test_list_models(self):
from plugins.image_gen.krea import KreaImageGenProvider
models = KreaImageGenProvider().list_models()
ids = {m["id"] for m in models}
assert {"krea-2-medium", "krea-2-large"} <= ids
# Each entry carries the picker fields the registry expects.
for m in models:
assert m["display"]
assert m["speed"]
assert m["strengths"]
assert m["price"]
def test_default_model_is_medium(self):
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().default_model() == "krea-2-medium"
def test_get_setup_schema(self):
from plugins.image_gen.krea import KreaImageGenProvider
schema = KreaImageGenProvider().get_setup_schema()
assert schema["name"] == "Krea"
assert schema["badge"] == "paid"
env_vars = schema["env_vars"]
assert len(env_vars) == 1
assert env_vars[0]["key"] == "KREA_API_KEY"
assert "krea.ai" in env_vars[0]["url"]
# ---------------------------------------------------------------------------
# Model resolution
# ---------------------------------------------------------------------------
class TestModelResolution:
def test_default(self):
from plugins.image_gen.krea import _resolve_model
model_id, meta = _resolve_model()
assert model_id == "krea-2-medium"
assert meta["path"] == "medium"
def test_env_override_large(self, monkeypatch):
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-large")
from plugins.image_gen.krea import _resolve_model
model_id, meta = _resolve_model()
assert model_id == "krea-2-large"
assert meta["path"] == "large"
def test_env_override_unknown_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-xxl-fake")
from plugins.image_gen.krea import _resolve_model
model_id, _ = _resolve_model()
assert model_id == "krea-2-medium"
def test_creativity_default(self):
from plugins.image_gen.krea import _resolve_creativity
assert _resolve_creativity(None) == "medium"
def test_creativity_valid(self):
from plugins.image_gen.krea import _resolve_creativity
assert _resolve_creativity("HIGH") == "high"
assert _resolve_creativity(" raw ") == "raw"
def test_creativity_invalid(self):
from plugins.image_gen.krea import _resolve_creativity
assert _resolve_creativity("ultra") == "medium"
# ---------------------------------------------------------------------------
# Generate — main flow
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_api_key(self, monkeypatch):
monkeypatch.delenv("KREA_API_KEY", raising=False)
from plugins.image_gen.krea import KreaImageGenProvider
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert "KREA_API_KEY" in result["error"]
assert result["error_type"] == "auth_required"
def test_empty_prompt(self):
from plugins.image_gen.krea import KreaImageGenProvider
result = KreaImageGenProvider().generate(prompt=" ")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
def test_successful_generation(self):
"""Happy path: submit → one poll → completed → URL downloaded."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job("https://krea.cdn/result.png"))
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/krea_krea-2-medium_test.png"),
) as mock_save, \
patch("plugins.image_gen.krea.time.sleep"): # skip real waits
result = KreaImageGenProvider().generate(prompt="A cinematic lamp")
assert result["success"] is True
assert result["image"] == "/tmp/krea_krea-2-medium_test.png"
assert result["provider"] == "krea"
assert result["model"] == "krea-2-medium"
assert result["aspect_ratio"] == "landscape"
assert result["job_id"] == "00000000-0000-0000-0000-000000000abc"
assert result["resolution"] == "1K"
assert result["creativity"] == "medium"
# Submit hit the medium endpoint
post_url = mock_post.call_args[0][0]
assert post_url.endswith("/generate/image/krea/krea-2/medium")
# Poll hit /jobs/{job_id}
poll_url = mock_get.call_args[0][0]
assert "/jobs/00000000-0000-0000-0000-000000000abc" in poll_url
# URL was materialised once
mock_save.assert_called_once()
def test_large_model_routes_to_large_endpoint(self, monkeypatch):
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-large")
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test")
post_url = mock_post.call_args[0][0]
assert post_url.endswith("/generate/image/krea/krea-2/large")
def test_aspect_ratio_mapping(self):
"""Hermes 'square' must map to Krea '1:1' in the wire payload."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test", aspect_ratio="square")
payload = mock_post.call_args.kwargs["json"]
assert payload["aspect_ratio"] == "1:1"
assert payload["resolution"] == "1K"
def test_auth_header(self):
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test")
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer test-key-12345"
assert headers["Content-Type"] == "application/json"
def test_passthrough_seed_styles_moodboards(self):
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(
prompt="test",
seed=42,
styles=[{"id": "lora-1", "strength": 0.7}],
moodboards=[{"url": "https://x.com/mood.png"}, {"url": "https://x.com/mood2.png"}],
image_style_references=[{"url": f"https://x.com/{i}.png"} for i in range(15)],
creativity="high",
)
payload = mock_post.call_args.kwargs["json"]
assert payload["seed"] == 42
assert payload["styles"] == [{"id": "lora-1", "strength": 0.7}]
assert len(payload["moodboards"]) == 1 # capped at 1
assert len(payload["image_style_references"]) == 10 # capped at 10
assert payload["creativity"] == "high"
def test_string_style_references_converted_to_objects(self):
"""Krea requires {url, strength} objects; bare URL strings must be
converted (a string yields a 422 'Expected object, received string')."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(
prompt="test",
image_style_references=[
"https://x.com/a.png",
{"url": "https://x.com/b.png", "strength": 1.2},
],
)
payload = mock_post.call_args.kwargs["json"]
assert payload["image_style_references"] == [
{"url": "https://x.com/a.png", "strength": 0.6},
{"url": "https://x.com/b.png", "strength": 1.2},
]
def test_unknown_kwargs_ignored(self):
"""Forward-compat: unknown kwargs must not break generate()."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(
prompt="test",
fictional_param="should be ignored",
num_images=4,
)
assert result["success"] is True
# ---------------------------------------------------------------------------
# Generate — error paths
# ---------------------------------------------------------------------------
class TestGenerateErrors:
def test_submit_http_error(self):
import requests as req_lib
from plugins.image_gen.krea import KreaImageGenProvider
resp = req_lib.Response()
resp.status_code = 401
resp._content = b'{"error": {"message": "Invalid API key"}}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(
side_effect=req_lib.HTTPError(response=resp)
)
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "401" in result["error"]
assert "Invalid API key" in result["error"]
def test_submit_timeout(self):
import requests as req_lib
from plugins.image_gen.krea import KreaImageGenProvider
with patch(
"plugins.image_gen.krea.requests.post", side_effect=req_lib.Timeout()
):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "timeout"
def test_submit_connection_error(self):
import requests as req_lib
from plugins.image_gen.krea import KreaImageGenProvider
with patch(
"plugins.image_gen.krea.requests.post",
side_effect=req_lib.ConnectionError("dns nope"),
):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "connection_error"
def test_submit_missing_job_id(self):
from plugins.image_gen.krea import KreaImageGenProvider
bad_submit = MagicMock()
bad_submit.status_code = 200
bad_submit.raise_for_status = MagicMock()
bad_submit.json.return_value = {"status": "queued"}
with patch("plugins.image_gen.krea.requests.post", return_value=bad_submit):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "invalid_response"
assert "job_id" in result["error"]
def test_job_failed(self):
from plugins.image_gen.krea import KreaImageGenProvider
failed = {
"job_id": "abc",
"status": "failed",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"error": "NSFW content"},
}
submit = _submit_response()
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(failed),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "NSFW" in result["error"]
def test_job_cancelled(self):
from plugins.image_gen.krea import KreaImageGenProvider
cancelled = {
"job_id": "abc",
"status": "cancelled",
"completed_at": "2026-05-27T00:01:00Z",
"result": {},
}
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(cancelled),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "cancelled"
def test_completed_but_missing_urls(self):
from plugins.image_gen.krea import KreaImageGenProvider
completed_empty = {
"job_id": "abc",
"status": "completed",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": []},
}
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(completed_empty),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_url_download_failure_falls_back_to_bare_url(self):
"""Mirror of xAI behaviour — if local cache fails, return the URL."""
import requests as req_lib
from plugins.image_gen.krea import KreaImageGenProvider
url = "https://krea.cdn/expired-soon.png"
submit = _submit_response()
poll = _poll_response(_completed_job(url))
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
side_effect=req_lib.HTTPError("404"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
assert result["image"] == url
def test_polling_picks_up_completed_at_with_unknown_status(self):
"""``completed_at`` set + unrecognised pending status → still terminal."""
from plugins.image_gen.krea import KreaImageGenProvider
# Use a status value that is NOT in our terminal set ("intermediate-complete")
# but with completed_at populated — Krea's spec says completed_at is the
# canonical terminal marker.
oddball = {
"job_id": "abc",
"status": "intermediate-complete",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": ["https://krea.cdn/done.png"]},
}
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(oddball),
), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
class TestPollRetryPolicy:
"""Polling fail-fast on permanent 4xx, retry on transient 5xx/429."""
def _http_error_response(self, status: int):
import requests as req_lib
resp = req_lib.Response()
resp.status_code = status
resp._content = b'{"error": "boom"}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(
side_effect=req_lib.HTTPError(response=resp)
)
return resp
def test_poll_fails_fast_on_401(self):
"""Auth failure mid-poll should not wait the 180s deadline."""
from plugins.image_gen.krea import KreaImageGenProvider
bad_poll = self._http_error_response(401)
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "401" in result["error"]
# One call — no retry on permanent auth failure.
assert mock_get.call_count == 1
def test_poll_fails_fast_on_404(self):
"""Missing job (404) should surface immediately, not retry for 180s."""
from plugins.image_gen.krea import KreaImageGenProvider
bad_poll = self._http_error_response(404)
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "404" in result["error"]
assert mock_get.call_count == 1
def test_poll_fails_fast_on_403(self):
"""Billing/permission failure (403) should not retry."""
from plugins.image_gen.krea import KreaImageGenProvider
bad_poll = self._http_error_response(403)
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert mock_get.call_count == 1
def test_poll_retries_on_503_then_succeeds(self):
"""Transient 5xx should retry and eventually surface a completion."""
from plugins.image_gen.krea import KreaImageGenProvider
flaky = self._http_error_response(503)
good = _poll_response(_completed_job("https://krea.cdn/ok.png"))
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
side_effect=[flaky, flaky, good],
) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
assert mock_get.call_count == 3
def test_poll_retries_on_429(self):
"""Rate-limit (429) is in the retryable set."""
from plugins.image_gen.krea import KreaImageGenProvider
rate_limited = self._http_error_response(429)
good = _poll_response(_completed_job("https://krea.cdn/ok.png"))
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
side_effect=[rate_limited, good],
) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
assert mock_get.call_count == 2
# ---------------------------------------------------------------------------
# Managed Nous gateway path
# ---------------------------------------------------------------------------
def _managed_cfg(
origin: str = "https://krea-gateway.example.com",
token: str = "nous-tok-abc",
):
from types import SimpleNamespace
return SimpleNamespace(
vendor="krea",
gateway_origin=origin,
nous_user_token=token,
managed_mode=True,
)
class TestManagedGateway:
def test_managed_submit_uses_gateway_origin_and_nous_token(self, monkeypatch):
"""Managed mode submits to the gateway origin with the Nous token."""
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
# Even with a direct key present, an active managed gateway wins.
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="A managed lamp")
assert result["success"] is True
post_url = mock_post.call_args[0][0]
assert post_url == (
"https://krea-gateway.example.com/generate/image/krea/krea-2/medium"
)
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer nous-tok-abc"
# Idempotency key drives the gateway's per-generation billing boundary.
assert headers["x-idempotency-key"]
# Poll is bound to the same gateway + Nous token.
poll_url = mock_get.call_args[0][0]
assert poll_url.startswith("https://krea-gateway.example.com/jobs/")
poll_headers = mock_get.call_args.kwargs["headers"]
assert poll_headers["Authorization"] == "Bearer nous-tok-abc"
def test_managed_available_without_direct_key(self, monkeypatch):
"""No KREA_API_KEY but an active gateway → generate proceeds (no auth_required)."""
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
monkeypatch.delenv("KREA_API_KEY", raising=False)
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
def test_managed_4xx_returns_actionable_remediation(self, monkeypatch):
import requests as req_lib
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
resp = req_lib.Response()
resp.status_code = 402
resp._content = b'{"error": {"message": "out of credits"}}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(side_effect=req_lib.HTTPError(response=resp))
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "402" in result["error"]
assert "Nous Subscription Krea gateway" in result["error"]
assert "KREA_API_KEY" in result["error"]
def test_managed_429_concurrency_hint(self, monkeypatch):
import requests as req_lib
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
resp = req_lib.Response()
resp.status_code = 429
resp._content = b'{"error": {"message": "maximum number of concurrent jobs"}}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(side_effect=req_lib.HTTPError(response=resp))
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert "429" in result["error"]
assert "concurrency" in result["error"].lower()
def test_managed_blocks_styles(self, monkeypatch):
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
with patch("plugins.image_gen.krea.requests.post") as mock_post:
result = KreaImageGenProvider().generate(
prompt="test",
styles=[{"id": "lora-1"}],
)
assert result["success"] is False
assert result["error_type"] == "unsupported_argument"
assert "LoRA" in result["error"] or "styles" in result["error"]
# Never hit the network with an unsupported tier.
mock_post.assert_not_called()
def test_managed_blocks_moodboards(self, monkeypatch):
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
with patch("plugins.image_gen.krea.requests.post") as mock_post:
result = KreaImageGenProvider().generate(
prompt="test",
moodboards=[{"url": "https://x.com/m.png"}],
)
assert result["success"] is False
assert result["error_type"] == "unsupported_argument"
assert "moodboard" in result["error"].lower()
mock_post.assert_not_called()
class TestExplicitModelOverride:
def test_model_kwarg_overrides_config(self, monkeypatch):
"""An explicit ``model`` kwarg (managed routing) wins over config/default."""
from plugins.image_gen.krea import _resolve_model
model_id, meta = _resolve_model("krea-2-large")
assert model_id == "krea-2-large"
assert meta["path"] == "large"
def test_turbo_routes_to_medium_turbo_endpoint(self):
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test", model="krea-2-medium-turbo")
assert result["success"] is True
assert result["model"] == "krea-2-medium-turbo"
post_url = mock_post.call_args[0][0]
assert post_url.endswith("/generate/image/krea/krea-2/medium-turbo")
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register(self):
from plugins.image_gen.krea import KreaImageGenProvider, register
mock_ctx = MagicMock()
register(mock_ctx)
mock_ctx.register_image_gen_provider.assert_called_once()
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
assert isinstance(provider, KreaImageGenProvider)
assert provider.name == "krea"
@@ -0,0 +1,324 @@
"""Tests for the bundled ``openai-codex`` image_gen plugin.
Mirrors ``test_openai_provider.py`` but targets the standalone
Codex/ChatGPT-OAuth-backed provider that uses the Responses
``image_generation`` tool path instead of the ``images.generate`` REST
endpoint.
"""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
# The plugin directory uses a hyphen, which is not a valid Python identifier
# for the dotted-import form. Load it via importlib so tests don't need to
# touch sys.path or rename the directory.
codex_plugin = importlib.import_module("plugins.image_gen.openai-codex")
# 1×1 transparent PNG — valid bytes for save_b64_image()
_PNG_HEX = (
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
"ae426082"
)
def _b64_png() -> str:
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
yield tmp_path
@pytest.fixture
def provider(monkeypatch):
# Codex plugin is API-key-independent; clear it to make the test honest.
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
return codex_plugin.OpenAICodexImageGenProvider()
# ── Metadata ────────────────────────────────────────────────────────────────
class TestMetadata:
def test_name(self, provider):
assert provider.name == "openai-codex"
def test_display_name(self, provider):
assert provider.display_name == "OpenAI (Codex auth)"
def test_default_model(self, provider):
assert provider.default_model() == "gpt-image-2-medium"
def test_list_models_three_tiers(self, provider):
ids = [m["id"] for m in provider.list_models()]
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
def test_setup_schema_has_no_required_env_vars(self, provider):
schema = provider.get_setup_schema()
assert schema["env_vars"] == []
assert schema["badge"] == "free"
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_unavailable_without_codex_token(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
def test_available_with_codex_token(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
# Codex plugin is intentionally orthogonal to the API-key plugin —
# the API key alone must NOT make it appear available.
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
# ── Generate ────────────────────────────────────────────────────────────────
class TestGenerate:
def test_returns_auth_error_without_codex_token(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_returns_invalid_argument_for_empty_prompt(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
result = provider.generate(" ")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: _b64_png())
result = provider.generate("a cat", aspect_ratio="landscape")
assert result["success"] is True
assert result["model"] == "gpt-image-2-medium"
assert result["provider"] == "openai-codex"
assert result["quality"] == "medium"
saved = Path(result["image"])
assert saved.exists()
assert saved.parent == tmp_path / "cache" / "images"
# Filename prefix differs from the API-key plugin so cache audits can
# tell the two backends apart.
assert saved.name.startswith("openai_codex_")
def test_codex_stream_request_shape(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
captured = {}
def _collect(token, *, prompt, size, quality, input_images=None):
captured.update(codex_plugin._build_responses_payload(
prompt=prompt,
size=size,
quality=quality,
input_images=input_images,
))
return _b64_png()
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
result = provider.generate("a cat", aspect_ratio="portrait")
assert result["success"] is True
assert captured["model"] == "gpt-5.5"
assert captured["store"] is False
assert captured["input"][0]["type"] == "message"
assert captured["input"][0]["role"] == "user"
assert captured["input"][0]["content"][0]["type"] == "input_text"
assert captured["tool_choice"]["type"] == "allowed_tools"
assert captured["tool_choice"]["mode"] == "required"
assert captured["tool_choice"]["tools"] == [{"type": "image_generation"}]
tool = captured["tools"][0]
assert tool["type"] == "image_generation"
assert tool["model"] == "gpt-image-2"
assert tool["quality"] == "medium"
assert tool["size"] == "1024x1536"
assert tool["output_format"] == "png"
assert tool["background"] == "opaque"
assert tool["partial_images"] == 1
def test_capabilities_advertise_image_inputs(self, provider):
caps = provider.capabilities()
assert caps["modalities"] == ["text", "image"]
assert caps["max_reference_images"] == 16
def test_codex_stream_request_includes_source_images(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
image_path = tmp_path / "source.png"
image_path.write_bytes(bytes.fromhex(_PNG_HEX))
captured = {}
def _collect(token, *, prompt, size, quality, input_images=None):
captured.update(codex_plugin._build_responses_payload(
prompt=prompt,
size=size,
quality=quality,
input_images=input_images,
))
return _b64_png()
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
result = provider.generate(
"put this same person in a navy JK uniform",
aspect_ratio="portrait",
image_url=str(image_path),
reference_image_urls=["https://example.com/ref.png"],
)
assert result["success"] is True
assert result["modality"] == "image"
assert result["input_image_count"] == 2
content = captured["input"][0]["content"]
assert content[0] == {
"type": "input_text",
"text": "put this same person in a navy JK uniform",
}
assert content[1]["type"] == "input_image"
assert content[1]["image_url"].startswith("data:image/png;base64,")
assert content[2] == {"type": "input_image", "image_url": "https://example.com/ref.png"}
def test_generate_clamps_reference_images_to_cap(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
captured = {}
def _collect(token, *, prompt, size, quality, input_images=None):
captured["input_images"] = input_images
return _b64_png()
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
refs = [f"https://example.com/ref-{idx}.png" for idx in range(20)]
result = provider.generate("combine the references", reference_image_urls=refs)
assert result["success"] is True
assert result["modality"] == "image"
assert result["input_image_count"] == 16
assert len(captured["input_images"]) == 16
assert captured["input_images"][-1]["image_url"] == "https://example.com/ref-15.png"
def test_rejects_non_image_local_source(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
text_path = tmp_path / "not-image.txt"
text_path.write_text("hello")
result = provider.generate("edit this", image_url=str(text_path))
assert result["success"] is False
assert result["error_type"] == "invalid_image_input"
assert "not a supported image" in result["error"]
def test_rejects_svg_local_source(self, provider, monkeypatch, tmp_path):
# The shared magic-byte sniffer recognizes SVG, but gpt-image-2's
# input_image accepts raster only — SVG must fail locally with a clear
# error, not get embedded and rejected server-side with an opaque 400.
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
svg_path = tmp_path / "vector.svg"
svg_path.write_text('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
result = provider.generate("edit this", image_url=str(svg_path))
assert result["success"] is False
assert result["error_type"] == "invalid_image_input"
assert "not a supported image" in result["error"]
def test_partial_image_event_used_when_done_missing(self):
"""If output_item.done is missing, partial_image_b64 is accepted."""
payload = {
"type": "response.image_generation_call.partial_image",
"partial_image_b64": _b64_png(),
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
def test_sse_parser_handles_event_and_data_lines(self):
class _Response:
def iter_lines(self):
return iter([
"event: response.output_item.done",
'data: {"item": {"type": "image_generation_call", "result": "abc"}}',
"",
])
events = list(codex_plugin._iter_sse_json(_Response()))
assert events == [{
"type": "response.output_item.done",
"item": {"type": "image_generation_call", "result": "abc"},
}]
def test_final_response_sweep_recovers_image(self):
"""Completed response output is found by recursive payload scanning."""
payload = {
"type": "response.completed",
"response": {
"output": [{
"type": "image_generation_call",
"status": "completed",
"id": "ig_final",
"result": _b64_png(),
}],
},
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
def test_empty_response_returns_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: None)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_stream_exception_returns_api_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
def _boom(*args, **kwargs):
raise RuntimeError("cloudflare 403")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _boom)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "cloudflare 403" in result["error"]
# ── Plugin entry point ──────────────────────────────────────────────────────
class TestRegistration:
def test_register_calls_register_image_gen_provider(self):
registered = []
class _Ctx:
def register_image_gen_provider(self, prov):
registered.append(prov)
codex_plugin.register(_Ctx())
assert len(registered) == 1
assert registered[0].name == "openai-codex"
@@ -0,0 +1,333 @@
"""Tests for the bundled OpenAI image_gen plugin (gpt-image-2, three tiers)."""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import plugins.image_gen.openai as openai_plugin
# 1×1 transparent PNG — valid bytes for save_b64_image()
_PNG_HEX = (
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
"ae426082"
)
def _b64_png() -> str:
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
def _fake_response(*, b64=None, url=None, revised_prompt=None):
item = SimpleNamespace(b64_json=b64, url=url, revised_prompt=revised_prompt)
return SimpleNamespace(data=[item])
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
yield tmp_path
@pytest.fixture
def provider(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
return openai_plugin.OpenAIImageGenProvider()
def _patched_openai(fake_client: MagicMock):
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
return patch.dict("sys.modules", {"openai": fake_openai})
# ── Metadata ────────────────────────────────────────────────────────────────
class TestMetadata:
def test_name(self, provider):
assert provider.name == "openai"
def test_default_model(self, provider):
assert provider.default_model() == "gpt-image-2-medium"
def test_list_models_three_tiers(self, provider):
ids = [m["id"] for m in provider.list_models()]
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
def test_catalog_entries_have_display_speed_strengths(self, provider):
for entry in provider.list_models():
assert entry["display"].startswith("GPT Image 2")
assert entry["speed"]
assert entry["strengths"]
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_no_api_key_unavailable(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
assert openai_plugin.OpenAIImageGenProvider().is_available() is False
def test_api_key_set_available(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
assert openai_plugin.OpenAIImageGenProvider().is_available() is True
# ── Model resolution ────────────────────────────────────────────────────────
class TestModelResolution:
def test_default_is_medium(self):
model_id, meta = openai_plugin._resolve_model()
assert model_id == "gpt-image-2-medium"
assert meta["quality"] == "medium"
def test_env_var_override(self, monkeypatch):
monkeypatch.setenv("OPENAI_IMAGE_MODEL", "gpt-image-2-high")
model_id, meta = openai_plugin._resolve_model()
assert model_id == "gpt-image-2-high"
assert meta["quality"] == "high"
def test_env_var_unknown_falls_back(self, monkeypatch):
monkeypatch.setenv("OPENAI_IMAGE_MODEL", "bogus-tier")
model_id, _ = openai_plugin._resolve_model()
assert model_id == openai_plugin.DEFAULT_MODEL
def test_config_openai_model(self, tmp_path):
import yaml
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"image_gen": {"openai": {"model": "gpt-image-2-low"}}})
)
model_id, meta = openai_plugin._resolve_model()
assert model_id == "gpt-image-2-low"
assert meta["quality"] == "low"
def test_config_top_level_model(self, tmp_path):
"""``image_gen.model: gpt-image-2-high`` also works (top-level)."""
import yaml
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"image_gen": {"model": "gpt-image-2-high"}})
)
model_id, meta = openai_plugin._resolve_model()
assert model_id == "gpt-image-2-high"
assert meta["quality"] == "high"
# ── Generate ────────────────────────────────────────────────────────────────
class TestSourceImageLoading:
def test_load_image_bytes_blocks_credential_store(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
openai_plugin._load_image_bytes(str(auth_json))
def test_load_image_bytes_never_opens_blocked_credential(self, tmp_path, monkeypatch):
"""The guard must fire BEFORE the file is opened — a credential store
must never be read into memory (#57698). Spy builtins.open and assert
it is never called for the blocked path."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import builtins
real_open = builtins.open
opened: list = []
def _spy_open(file, *a, **k):
opened.append(str(file))
return real_open(file, *a, **k)
monkeypatch.setattr(builtins, "open", _spy_open)
with pytest.raises(ValueError, match="credential store"):
openai_plugin._load_image_bytes(str(auth_json))
assert str(auth_json) not in opened, "blocked credential must never be opened"
def test_load_image_bytes_allows_legit_local_image(self, tmp_path, monkeypatch):
"""Negative control: a legitimate local image path is NOT blocked and
loads normally — proves the guard doesn't over-fire on everything."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
img = tmp_path / "pic.png"
img.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
data, name = openai_plugin._load_image_bytes(str(img))
assert data == b"\x89PNG\r\n\x1a\nfake-image-bytes"
assert name == "pic.png"
def test_load_image_bytes_passthrough_data_uri_not_blocked(self, tmp_path, monkeypatch):
"""Negative control: data: URIs are decoded, never routed through the
local-path guard (the guard only applies to local file reads)."""
import base64
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
b64 = base64.b64encode(b"xyz").decode("ascii")
data, name = openai_plugin._load_image_bytes(f"data:image/png;base64,{b64}")
assert data == b"xyz"
assert name.endswith(".png")
class TestGenerate:
def test_empty_prompt_rejected(self, provider):
result = provider.generate("", aspect_ratio="square")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
def test_missing_api_key(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
result = openai_plugin.OpenAIImageGenProvider().generate("a cat")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_b64_saves_to_cache(self, provider, tmp_path):
png_bytes = bytes.fromhex(_PNG_HEX)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat", aspect_ratio="landscape")
assert result["success"] is True
assert result["model"] == "gpt-image-2-medium"
assert result["aspect_ratio"] == "landscape"
assert result["provider"] == "openai"
assert result["quality"] == "medium"
saved = Path(result["image"])
assert saved.exists()
assert saved.parent == tmp_path / "cache" / "images"
assert saved.read_bytes() == png_bytes
call_kwargs = fake_client.images.generate.call_args.kwargs
# All tiers hit the single underlying API model.
assert call_kwargs["model"] == "gpt-image-2"
assert call_kwargs["quality"] == "medium"
assert call_kwargs["size"] == "1536x1024"
# gpt-image-2 rejects response_format — we must NOT send it.
assert "response_format" not in call_kwargs
@pytest.mark.parametrize("tier,expected_quality", [
("gpt-image-2-low", "low"),
("gpt-image-2-medium", "medium"),
("gpt-image-2-high", "high"),
])
def test_tier_maps_to_quality(self, provider, monkeypatch, tier, expected_quality):
monkeypatch.setenv("OPENAI_IMAGE_MODEL", tier)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["model"] == tier
assert result["quality"] == expected_quality
assert fake_client.images.generate.call_args.kwargs["quality"] == expected_quality
# Always the same underlying API model regardless of tier.
assert fake_client.images.generate.call_args.kwargs["model"] == "gpt-image-2"
@pytest.mark.parametrize("aspect,expected_size", [
("landscape", "1536x1024"),
("square", "1024x1024"),
("portrait", "1024x1536"),
])
def test_aspect_ratio_mapping(self, provider, aspect, expected_size):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
provider.generate("a cat", aspect_ratio=aspect)
assert fake_client.images.generate.call_args.kwargs["size"] == expected_size
def test_revised_prompt_passed_through(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=_b64_png(), revised_prompt="A photo of a cat",
)
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["revised_prompt"] == "A photo of a cat"
def test_api_error_returns_error_response(self, provider):
fake_client = MagicMock()
fake_client.images.generate.side_effect = RuntimeError("boom")
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "boom" in result["error"]
def test_empty_response_data(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = SimpleNamespace(data=[])
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_url_response_is_cached_locally(self, provider):
"""OpenAI URL response (if API ever returns one) is cached locally.
Pre-fix this asserted the bare URL passed through; symmetric to the
xAI #26942 fix. Even though gpt-image-2 returns b64 today, every
``image_gen`` provider must guarantee the gateway gets a stable
file path so ephemeral signed URLs can't expire mid-flight.
"""
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=None, url="https://example.com/img.png",
)
with _patched_openai(fake_client), patch(
"plugins.image_gen.openai.save_url_image",
return_value=Path("/tmp/openai_gpt-image-2_20260524_000000_deadbeef.png"),
) as mock_save_url:
result = provider.generate("a cat")
assert result["success"] is True
assert result["image"].startswith("/")
assert "example.com" not in result["image"]
mock_save_url.assert_called_once()
def test_url_response_falls_back_to_bare_url_when_download_fails(self, provider):
"""Cache failure must not turn into a tool error — symmetric with xAI."""
import requests as req_lib
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=None, url="https://example.com/img.png",
)
with _patched_openai(fake_client), patch(
"plugins.image_gen.openai.save_url_image",
side_effect=req_lib.HTTPError("404 from CDN"),
):
result = provider.generate("a cat")
assert result["success"] is True
assert result["image"] == "https://example.com/img.png"
@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""Tests for the OpenRouter-compatible image gen provider (OpenRouter + Nous)."""
from __future__ import annotations
import base64
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_RUNTIME = "hermes_cli.runtime_provider.resolve_runtime_provider"
_PNG_DATA_URI = "data:image/png;base64,dGVzdC1pbWFnZS1kYXRh" # "test-image-data"
def _runtime_ok(**over):
base = {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test",
"source": "env",
}
base.update(over)
return base
def _mock_chat_response(images):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"choices": [
{
"message": {
"role": "assistant",
"content": "",
"images": [
{"type": "image_url", "image_url": {"url": u}} for u in images
],
}
}
]
}
return resp
def _openrouter():
from plugins.image_gen.openrouter import OpenRouterCompatImageProvider
return OpenRouterCompatImageProvider(
provider_name="openrouter",
display_name="OpenRouter",
runtime_name="openrouter",
config_key="openrouter",
model_env_var="OPENROUTER_IMAGE_MODEL",
setup_schema={"name": "OpenRouter (image)", "badge": "paid", "env_vars": []},
)
# ---------------------------------------------------------------------------
# Provider class
# ---------------------------------------------------------------------------
class TestProviderClass:
def test_names(self):
from plugins.image_gen.openrouter import _build_providers
names = {p.name for p in _build_providers()}
assert names == {"openrouter", "nous"}
def test_display_names(self):
from plugins.image_gen.openrouter import _build_providers
by_name = {p.name: p for p in _build_providers()}
assert by_name["openrouter"].display_name == "OpenRouter"
assert by_name["nous"].display_name == "Nous Portal"
def test_capabilities_support_image_input(self):
caps = _openrouter().capabilities()
assert "image" in caps["modalities"]
assert caps["max_reference_images"] >= 1
def test_is_available_with_key(self):
with patch(_RUNTIME, return_value=_runtime_ok()):
assert _openrouter().is_available() is True
def test_is_available_without_key(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
assert _openrouter().is_available() is False
def test_is_available_on_resolution_error(self):
with patch(_RUNTIME, side_effect=RuntimeError("boom")):
assert _openrouter().is_available() is False
def test_default_model(self):
from plugins.image_gen.openrouter import DEFAULT_MODEL
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
assert _openrouter().default_model() == DEFAULT_MODEL
# Default must be an image-output model id (provider/model form).
assert "/" in DEFAULT_MODEL and "image" in DEFAULT_MODEL
def test_default_chain_prefers_quality_then_fallback(self):
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _DEFAULT_MODEL_CHAIN
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
chain = _openrouter()._resolve_model_chain()
assert chain == list(_DEFAULT_MODEL_CHAIN)
assert chain[0].startswith("openai/")
assert chain[-1] == _FALLBACK_MODEL
def test_model_env_override(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "black-forest-labs/flux.2-pro")
assert _openrouter()._resolve_model() == "black-forest-labs/flux.2-pro"
assert _openrouter()._resolve_model_chain() == ["black-forest-labs/flux.2-pro"]
def test_model_config_override(self):
cfg = {"openrouter": {"model": "google/gemini-3.1-flash-image-preview"}}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model() == "google/gemini-3.1-flash-image-preview"
def test_model_top_level_config_override(self):
cfg = {"model": "openai/gpt-image-2"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model_chain() == ["openai/gpt-image-2"]
def test_nous_honors_top_level_model(self):
from plugins.image_gen.openrouter import _build_providers
cfg = {"model": "openai/gpt-image-2"}
nous = {p.name: p for p in _build_providers()}["nous"]
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert nous._resolve_model_chain() == ["openai/gpt-image-2"]
def test_explicit_model_kwarg_wins_over_config(self):
cfg = {"model": "openai/gpt-image-2"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model_chain("google/gemini-3-pro-image") == [
"google/gemini-3-pro-image"
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class TestHelpers:
def test_to_image_url_part_passthrough_url(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("https://x/y.png") == "https://x/y.png"
assert _to_image_url_part("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
def test_to_image_url_part_inlines_local_file(self, tmp_path):
from plugins.image_gen.openrouter import _to_image_url_part
f = tmp_path / "base.png"
f.write_bytes(b"\x89PNG\r\n")
part = _to_image_url_part(str(f))
assert part.startswith("data:image/png;base64,")
decoded = base64.b64decode(part.split(",", 1)[1])
assert decoded == b"\x89PNG\r\n"
def test_to_image_url_part_missing_file(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("/no/such/file.png") is None
def test_to_image_url_part_blocks_credential_store(self, tmp_path, monkeypatch):
from plugins.image_gen.openrouter import _to_image_url_part
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
_to_image_url_part(str(auth_json))
def test_to_image_url_part_never_reads_blocked_credential(self, tmp_path, monkeypatch):
"""The guard must fire BEFORE path.read_bytes() — the credential store
must never be inlined into a provider request (#57698)."""
from pathlib import Path as _P
from plugins.image_gen.openrouter import _to_image_url_part
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
real_read_bytes = _P.read_bytes
read: list = []
def _spy_read_bytes(self, *a, **k):
read.append(str(self))
return real_read_bytes(self, *a, **k)
monkeypatch.setattr(_P, "read_bytes", _spy_read_bytes)
with pytest.raises(ValueError, match="credential store"):
_to_image_url_part(str(auth_json))
assert str(auth_json) not in read, "blocked credential must never be read"
def test_extract_images(self):
from plugins.image_gen.openrouter import _extract_images
payload = {
"choices": [
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,AA"}}]}}
]
}
assert _extract_images(payload) == ["data:image/png;base64,AA"]
def test_extract_images_empty(self):
from plugins.image_gen.openrouter import _extract_images
assert _extract_images({"choices": [{"message": {"content": "no image"}}]}) == []
def test_access_error_hint_for_gated_openai_model(self):
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _access_error_hint
hint = _access_error_hint(
"OpenRouter", "openai/gpt-5.4-image-2", "OPENROUTER_IMAGE_MODEL", 404, "No endpoints found"
)
assert hint is not None
assert "openai/gpt-5.4-image-2" in hint
assert "OPENROUTER_IMAGE_MODEL" in hint
assert _FALLBACK_MODEL in hint
# Stays a single line under the humanizer's 200-char truncation.
assert "\n" not in hint and len(hint) <= 200
def test_access_error_hint_ignores_non_openai_models(self):
from plugins.image_gen.openrouter import _access_error_hint
assert _access_error_hint("OpenRouter", "google/gemini-3-pro-image", "X", 404, "boom") is None
def test_access_error_hint_ignores_unrelated_errors(self):
from plugins.image_gen.openrouter import _access_error_hint
# A 200-class transient with an openai model but no access signal → no hint.
assert _access_error_hint("OpenRouter", "openai/gpt-5.4-image-2", "X", 500, "server error") is None
# ---------------------------------------------------------------------------
# generate()
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_credentials(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "missing_api_key"
def test_success_data_uri(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])), \
patch(
"plugins.image_gen.openrouter.save_b64_image",
return_value=Path("/tmp/openrouter_gen.png"),
) as mock_save:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen.png"
assert result["provider"] == "openrouter"
mock_save.assert_called_once()
def test_success_http_url(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response(["https://cdn/x.png"])), \
patch(
"plugins.image_gen.openrouter.save_url_image",
return_value=Path("/tmp/openrouter_gen_url.png"),
) as mock_save_url:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen_url.png"
mock_save_url.assert_called_once()
def test_empty_response(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([])):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_payload_shape_and_references(self, tmp_path):
"""Wire payload must carry image modalities, aspect_ratio, and the
reference image inlined as a data URI (this is what makes pet rows
stay on-model)."""
ref = tmp_path / "base.png"
ref.write_bytes(b"\x89PNG\r\n")
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(
prompt="a pet", aspect_ratio="square", reference_images=[str(ref)]
)
payload = mock_post.call_args.kwargs["json"]
assert payload["modalities"] == ["image", "text"]
assert payload["image_config"]["aspect_ratio"] == "1:1"
content = payload["messages"][0]["content"]
assert content[0] == {"type": "text", "text": "a pet"}
image_parts = [c for c in content if c["type"] == "image_url"]
assert len(image_parts) == 1
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
def test_auth_header(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(prompt="a pet")
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer sk-or-test"
def test_generate_uses_model_kwarg_from_dispatch(self):
"""image_generate passes image_gen.model as a model kwarg — honor it."""
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
result = _openrouter().generate(prompt="a pet", model="openai/gpt-image-2")
assert result["success"] is True
assert result["model"] == "openai/gpt-image-2"
assert mock_post.call_args.kwargs["json"]["model"] == "openai/gpt-image-2"
def test_posts_to_resolved_base_url(self):
"""Nous routes to its own base URL — proves the same code serves both."""
nous_runtime = _runtime_ok(
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
)
with patch(_RUNTIME, return_value=nous_runtime), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
from plugins.image_gen.openrouter import _build_providers
nous = {p.name: p for p in _build_providers()}["nous"]
result = nous.generate(prompt="a pet")
assert result["success"] is True
assert result["provider"] == "nous"
url = mock_post.call_args[0][0]
assert url == "https://inference.nousresearch.com/v1/chat/completions"
def test_api_error(self):
import requests as req_lib
resp = MagicMock()
resp.status_code = 401
resp.text = "Unauthorized"
resp.json.return_value = {"error": {"message": "Invalid API key"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp) as mock_post:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert mock_post.call_count == 1
def test_timeout(self):
import requests as req_lib
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", side_effect=req_lib.Timeout()):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "timeout"
def test_access_gated_model_surfaces_hint(self, monkeypatch):
"""A 404 on an OpenAI image model yields the actionable access hint (not
the misleading generic 'check your key' message)."""
import requests as req_lib
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "openai/gpt-5.4-image-2")
resp = MagicMock()
resp.status_code = 404
resp.text = "No endpoints found for openai/gpt-5.4-image-2"
resp.json.return_value = {"error": {"message": "No endpoints found"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp) as mock_post:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "model_access"
assert "OpenAI image access" in result["error"]
assert mock_post.call_count == 1 # explicit override: no auto-fallback chain
def test_access_gated_default_model_falls_back_to_gemini(self):
import requests as req_lib
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
gated = MagicMock()
gated.status_code = 404
gated.text = f"No endpoints found for {DEFAULT_MODEL}"
gated.json.return_value = {"error": {"message": "No endpoints found"}}
gated.raise_for_status.side_effect = req_lib.HTTPError(response=gated)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", side_effect=[gated, _mock_chat_response([_PNG_DATA_URI])]) as mock_post, \
patch(
"plugins.image_gen.openrouter.save_b64_image",
return_value=Path("/tmp/openrouter_gen_fallback.png"),
):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["model"] == _FALLBACK_MODEL
assert result["image"] == "/tmp/openrouter_gen_fallback.png"
assert mock_post.call_count == 2
first_model = mock_post.call_args_list[0].kwargs["json"]["model"]
second_model = mock_post.call_args_list[1].kwargs["json"]["model"]
assert first_model == DEFAULT_MODEL
assert second_model == _FALLBACK_MODEL
# ---------------------------------------------------------------------------
# Registration + pet integration
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register_both(self):
from plugins.image_gen.openrouter import register
ctx = MagicMock()
register(ctx)
registered = [c.args[0].name for c in ctx.register_image_gen_provider.call_args_list]
assert set(registered) == {"openrouter", "nous"}
def test_both_are_reference_capable_for_pets(self):
from agent.pet.generate.imagegen import _REF_CAPABLE
assert "openrouter" in _REF_CAPABLE
assert "nous" in _REF_CAPABLE
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""Tests for xAI image generation provider."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _fake_api_key(monkeypatch, tmp_path):
"""Ensure XAI_API_KEY is set for all tests."""
monkeypatch.setenv("XAI_API_KEY", "test-key-12345")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
try:
import hermes_cli.config as cfg_mod
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
cfg_mod._invalidate_load_config_cache()
except Exception:
pass
# ---------------------------------------------------------------------------
# Provider class tests
# ---------------------------------------------------------------------------
class TestXAIImageGenProvider:
def test_name(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.name == "xai"
def test_display_name(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.display_name == "xAI (Grok)"
def test_is_available_with_key(self, monkeypatch):
monkeypatch.setenv("XAI_API_KEY", "sk-xxx")
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.is_available() is True
def test_is_available_without_key(self, monkeypatch):
monkeypatch.delenv("XAI_API_KEY", raising=False)
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.is_available() is False
def test_list_models(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
models = provider.list_models()
assert len(models) >= 1
assert models[0]["id"] == "grok-imagine-image"
def test_default_model(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.default_model() == "grok-imagine-image"
def test_get_setup_schema(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
schema = provider.get_setup_schema()
assert schema["name"] == "xAI Grok Imagine (image)"
assert schema["badge"] == "paid"
# Auth resolution is delegated to the shared "xai_grok" post_setup
# hook so the picker doesn't blindly prompt for XAI_API_KEY when the
# user is already signed in via xAI Grok OAuth.
assert schema["env_vars"] == []
assert schema["post_setup"] == "xai_grok"
def test_capabilities_expose_total_source_image_limit(self):
from plugins.image_gen.xai import XAIImageGenProvider
caps = XAIImageGenProvider().capabilities()
assert caps["max_reference_images"] == 2
assert caps["max_source_images"] == 3
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestConfig:
def test_default_model(self):
from plugins.image_gen.xai import _resolve_model
model_id, meta = _resolve_model()
assert model_id == "grok-imagine-image"
def test_default_resolution(self):
from plugins.image_gen.xai import _resolve_resolution
assert _resolve_resolution() == "1k"
def test_custom_model(self, monkeypatch):
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
from plugins.image_gen.xai import _resolve_model
model_id, _ = _resolve_model()
assert model_id == "grok-imagine-image"
# ---------------------------------------------------------------------------
# Generate tests
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_api_key(self, monkeypatch):
monkeypatch.delenv("XAI_API_KEY", raising=False)
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert "XAI_API_KEY" in result["error"]
def test_successful_generation(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"b64_json": "dGVzdC1pbWFnZS1kYXRh"}], # base64 "test-image-data"
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
with patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True
assert result["image"] == "/tmp/test.png"
assert result["provider"] == "xai"
assert result["model"] == "grok-imagine-image"
def test_successful_url_response(self):
"""xAI URL response is cached locally — #26942 contract.
Pre-fix this asserted ``result["image"] == "<the bare URL>"``, which
was exactly the bug: xAI's ``imgen.x.ai/xai-tmp-*`` URLs expire fast
and the gateway 404'd by ``send_photo`` time. Post-fix the URL
bytes are downloaded at tool-completion and the result carries an
absolute filesystem path the gateway can upload from.
"""
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg"}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
patch(
"plugins.image_gen.xai.save_url_image",
return_value=Path("/tmp/xai_grok-imagine-image_20260524_000000_deadbeef.jpg"),
) as mock_save_url:
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True
assert result["image"].startswith("/"), (
f"URL response must be cached to an absolute path, got {result['image']!r}"
)
assert "imgen.x.ai" not in result["image"], (
"ephemeral xAI URL must not leak into result.image — caller will 404"
)
# The downloader should have been called exactly once with the URL
# and an xai-prefixed cache filename.
mock_save_url.assert_called_once()
call_args, call_kwargs = mock_save_url.call_args
assert call_args[0] == "https://imgen.x.ai/xai-tmp-imgen-test.jpeg"
assert call_kwargs.get("prefix", "").startswith("xai_")
def test_url_response_falls_back_to_bare_url_when_download_fails(self):
"""If caching the URL fails (network blip, 404 in-flight), the
provider must NOT hard-error — fall through to returning the bare
URL so the agent surface at least sees *something*. The gateway's
existing URL-send fallback then has a chance to succeed; if it
too 404s, the user gets the original (now legible) error rather
than an opaque "image generation failed" tool result.
"""
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
patch(
"plugins.image_gen.xai.save_url_image",
side_effect=req_lib.HTTPError("404 from CDN"),
):
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True, (
"Cache failure must not turn into a tool error — gateway gets a chance to retry"
)
assert result["image"] == "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"
def test_api_error(self):
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 401
mock_resp.text = "Unauthorized"
mock_resp.json.return_value = {"error": {"message": "Invalid API key"}}
mock_resp.raise_for_status.side_effect = req_lib.HTTPError(response=mock_resp)
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
def test_api_error_preserves_real_response_status(self):
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
response = req_lib.Response()
response.status_code = 401
response._content = json.dumps({"error": {"message": "Invalid API key"}}).encode()
response.headers["Content-Type"] = "application/json"
response.raise_for_status = MagicMock(
side_effect=req_lib.HTTPError(response=response)
)
with patch("plugins.image_gen.xai.requests.post", return_value=response):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "xAI image generation failed (401): Invalid API key" in result["error"]
def test_timeout(self):
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
with patch("plugins.image_gen.xai.requests.post", side_effect=req_lib.Timeout()):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "timeout"
def test_empty_response(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": []}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_auth_header(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"url": "https://xai.image/test.png"}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
provider = XAIImageGenProvider()
provider.generate(prompt="test")
call_args = mock_post.call_args
headers = call_args.kwargs.get("headers") or call_args[1].get("headers")
assert "Bearer test-key-12345" in headers["Authorization"]
assert "Hermes-Agent" in headers["User-Agent"]
def test_payload_resolution_is_literal_1k_or_2k(self):
"""Regression: xAI API rejects numeric resolutions ("1024"/"2048") with 422.
The endpoint expects the literal strings "1k" or "2k". Ensure the wire
payload carries that literal — not a numeric mapping. See PR #18678.
"""
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/test.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
provider = XAIImageGenProvider()
provider.generate(prompt="test")
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["resolution"] in {"1k", "2k"}, (
f"resolution must be the literal '1k' or '2k', got {payload['resolution']!r}"
)
def test_image_edit_rejects_bare_file_id_input(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="make the robot red",
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
)
assert result["success"] is False
assert result["error_type"] == "invalid_image_url"
mock_post.assert_not_called()
def test_image_edit_accepts_public_https_url(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
public_url = "https://files-cdn.x.ai/token/file_abc.png"
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="make the robot red",
image_url=public_url,
)
assert result["success"] is True
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["image"] == {"url": public_url, "type": "image_url"}
def test_multi_image_edit_rejects_bare_file_id_inputs(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="combine these robots into one product shot",
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
reference_image_urls=[
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
"file_aa11bb22-cc33-44dd-88ee-ff0011223344",
],
)
assert result["success"] is False
assert result["error_type"] == "invalid_image_url"
mock_post.assert_not_called()
def test_multi_image_edit_rejects_more_than_three_sources(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
result = provider.generate(
prompt="combine too many references",
image_url="file_1",
reference_image_urls=["file_2", "file_3", "file_4"],
)
assert result["success"] is False
assert result["error_type"] == "too_many_references"
def test_storage_options_are_sent_by_default(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
provider = XAIImageGenProvider()
provider.generate(prompt="test")
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["storage_options"]["public_url"] is True
assert "expires_after" not in payload["storage_options"]
assert payload["storage_options"]["filename"].endswith(".png")
def test_public_url_file_output_wins_over_temporary_url(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{
"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg",
"file_output": {
"file_id": "file-123",
"filename": "stored.png",
"public_url": "https://xai-files.example/stored.png",
"public_url_expires_at": 1234567890,
},
}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
patch("plugins.image_gen.xai.save_url_image") as mock_save_url:
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True
assert result["image"] == "https://xai-files.example/stored.png"
assert result["public_url"] == "https://xai-files.example/stored.png"
assert "file_id" not in result
mock_save_url.assert_not_called()
# ---------------------------------------------------------------------------
# Registration test
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register(self):
from plugins.image_gen.xai import XAIImageGenProvider, register
mock_ctx = MagicMock()
register(mock_ctx)
mock_ctx.register_image_gen_provider.assert_called_once()
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
assert isinstance(provider, XAIImageGenProvider)
assert provider.name == "xai"
def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
"""A ~-prefixed local image path must load (expanduser), not raise io_error.
Pre-flight validation uses ``Path(source).expanduser()`` so a ``~/...`` path
passes; ``_xai_image_field`` must expand it too or the load fails spuriously.
"""
from plugins.image_gen.xai import _xai_image_field
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
img = tmp_path / "pic.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
field = _xai_image_field("~/pic.png")
assert field["type"] == "image_url"
assert field["url"].startswith("data:image/png;base64,")
class TestXAIImageFieldReadGuard:
"""#57698: local image inputs must not read Hermes credential stores."""
def test_xai_image_field_blocks_credential_store(self, tmp_path, monkeypatch):
from plugins.image_gen.xai import _xai_image_field
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
_xai_image_field(str(auth_json))
def test_xai_image_field_never_opens_blocked_credential(self, tmp_path, monkeypatch):
"""Guard fires before open() — credential store never read into memory."""
import builtins
from plugins.image_gen.xai import _xai_image_field
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
real_open = builtins.open
opened: list = []
def _spy_open(file, *a, **k):
opened.append(str(file))
return real_open(file, *a, **k)
monkeypatch.setattr(builtins, "open", _spy_open)
with pytest.raises(ValueError, match="credential store"):
_xai_image_field(str(auth_json))
assert str(auth_json) not in opened, "blocked credential must never be opened"
def test_xai_image_field_passthrough_url_not_blocked(self, monkeypatch):
"""Negative control: remote URLs and data: URIs pass through unguarded."""
from plugins.image_gen.xai import _xai_image_field
assert _xai_image_field("https://example.com/pic.png")["url"] == "https://example.com/pic.png"
assert _xai_image_field("data:image/png;base64,eHl6")["url"].startswith("data:image/png")
View File
@@ -0,0 +1,61 @@
"""Tests for the ByteRover memory provider config gates."""
from plugins.memory.byterover import ByteRoverMemoryProvider
def test_auto_extract_false_skips_sync_turn(monkeypatch):
calls = []
provider = ByteRoverMemoryProvider({"auto_extract": False})
provider.initialize("session-1")
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
provider.sync_turn("please remember this detail", "acknowledged")
assert calls == []
assert provider._sync_thread is None
def test_auto_extract_false_skips_memory_write(monkeypatch):
calls = []
provider = ByteRoverMemoryProvider({"auto_extract": "false"})
provider.initialize("session-1")
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
provider.on_memory_write("add", "user", "User prefers concise responses")
assert calls == []
def test_auto_extract_false_skips_pre_compress(monkeypatch):
calls = []
provider = ByteRoverMemoryProvider({"auto_extract": "off"})
provider.initialize("session-1")
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
result = provider.on_pre_compress([
{"role": "user", "content": "remember this"},
{"role": "assistant", "content": "stored"},
])
assert result == ""
assert calls == []
def test_auto_extract_false_keeps_explicit_curate_tool(monkeypatch):
calls = []
provider = ByteRoverMemoryProvider({"auto_extract": False})
provider.initialize("session-1")
def fake_run(args, **kwargs):
calls.append(args)
return {"success": True, "output": "ok"}
monkeypatch.setattr("plugins.memory.byterover._run_brv", fake_run)
result = provider.handle_tool_call("brv_curate", {"content": "Important project fact"})
assert "Memory curated successfully" in result
assert calls == [["curate", "--", "Important project fact"]]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
"""Tests for FactRetriever FTS5 query sanitization.
These tests cover the fix where raw natural-language queries passed to
FTS5 MATCH were AND-joined by default, dropping recall to zero on any
multi-word prose query. The sanitizer drops stopwords and OR-joins the
remaining content tokens as phrase literals.
"""
from __future__ import annotations
import pytest
pytest.importorskip("numpy") # retrieval module imports numpy indirectly
from plugins.memory.holographic.retrieval import FactRetriever
from plugins.memory.holographic.store import MemoryStore
# ---------------------------------------------------------------------------
# _sanitize_fts_query — unit tests (no DB required)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"query,expected_tokens",
[
# stopwords dropped
("what happened with the deployment rollback", {"happened", "deployment", "rollback"}),
# single content word passes through
("compaction", {"compaction"}),
# all stopwords → falls back to raw
("the and of", None), # None = sentinel for fallback-to-raw
# empty string → empty output
("", ""),
# FTS5 operator characters stripped
("context: length-probe", {"context", "lengthprobe"}),
# trailing punctuation stripped by tokenizer
("hello, world!", {"hello", "world"}),
],
)
def test_sanitize_fts_query_extracts_content_tokens(query, expected_tokens):
result = FactRetriever._sanitize_fts_query(query)
if expected_tokens == "":
assert result == ""
return
if expected_tokens is None:
# Pathological case: all stopwords — should fall back to raw query
assert result == query
return
# OR-joined phrase literals: `"tok1" OR "tok2" OR ...`
# Extract the tokens between quotes, order-independent.
import re
matches = re.findall(r'"([^"]+)"', result)
assert set(matches) == expected_tokens, f"got {result!r}"
def test_sanitize_fts_query_never_crashes_on_fts5_specials():
"""Queries with FTS5 operator characters must not produce malformed SQL."""
problematic = [
'test " query',
"test * query",
"test (a OR b) query",
"test^2 query",
"test:colon query",
"test-hyphen query",
"a" * 1000, # long query
]
for q in problematic:
result = FactRetriever._sanitize_fts_query(q)
# We just need it to return a string without raising
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# Integration test — actually run _fts_candidates against an in-memory DB
# ---------------------------------------------------------------------------
@pytest.fixture
def retriever_with_facts(tmp_path):
"""MemoryStore seeded with a few facts for retrieval tests."""
db_path = tmp_path / "test_facts.db"
store = MemoryStore(str(db_path))
store.add_fact(
content="The Thursday deployment rollback failed because of stale migration state.",
category="project",
)
store.add_fact(
content="Compaction settings tuned to 0.85 threshold.",
category="tool",
)
store.add_fact(
content="Venice.ai advertises availableContextTokens inside model_spec.",
category="tool",
)
retriever = FactRetriever(store=store)
yield retriever
store.close()
def test_prefetch_recovers_prose_query(retriever_with_facts):
"""A natural-language query should now match the relevant fact.
Before the sanitizer fix, 'what happened with the deployment rollback'
returned zero hits because FTS5 required every token to co-occur.
"""
results = retriever_with_facts.search(
"what happened with the deployment rollback"
)
assert len(results) >= 1
# The top hit should be the deployment rollback fact
assert "deployment rollback" in results[0]["content"].lower()
def test_prefetch_single_keyword_still_works(retriever_with_facts):
"""Single-term queries (pre-fix working case) remain working."""
results = retriever_with_facts.search("compaction")
assert len(results) >= 1
assert "Compaction" in results[0]["content"] or "compaction" in results[0]["content"].lower()
def test_prefetch_stopword_only_query_empty(retriever_with_facts):
"""Pure stopword queries return zero results but don't crash."""
# Pass to _sanitize_fts_query directly first so we know what happens
assert FactRetriever._sanitize_fts_query("the and of") == "the and of"
# search() handles the likely-zero-hit case gracefully
results = retriever_with_facts.search("the and of")
# Either zero results or it errored-gracefully to [] — both are fine
assert isinstance(results, list)
@@ -0,0 +1,57 @@
"""Regression test for #44037 — holographic provider leaked its SQLite
connection to GC on shutdown instead of closing it.
The corruption-mechanism framing in #44037 (TLS bytes written into the DB via
an fd-recycle race) was not reproducible from the code: dropping a sqlite
connection flushes valid pages through SQLite's own VFS, never TLS framing, and
the provider is at most a *releaser* of DB fds, not the TLS-flushing owner.
But the underlying resource-hygiene bug is real and is what this test pins:
``HolographicMemoryProvider.shutdown()`` must call ``MemoryStore.close()`` so
the ``check_same_thread=False`` connection's fd is released deterministically
on shutdown, rather than at a non-deterministic GC time on an arbitrary thread.
"""
import sqlite3
import pytest
from plugins.memory.holographic import HolographicMemoryProvider
def _make_provider(tmp_path):
db_path = str(tmp_path / "memory_store.db")
provider = HolographicMemoryProvider(config={"db_path": db_path, "hrr_dim": 64})
provider.initialize(session_id="test-session")
return provider
def test_shutdown_closes_store_connection(tmp_path):
provider = _make_provider(tmp_path)
store = provider._store
assert store is not None
conn = store._conn
# Connection is live before shutdown.
conn.execute("SELECT 1").fetchone()
provider.shutdown()
# References are dropped...
assert provider._store is None
assert provider._retriever is None
# ...AND the underlying connection was actually closed (not left to GC).
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_shutdown_is_idempotent_and_safe_without_store(tmp_path):
provider = _make_provider(tmp_path)
provider.shutdown()
# Second shutdown (store already None) must not raise.
provider.shutdown()
# A provider that was never initialized must also shut down cleanly.
bare = HolographicMemoryProvider(config={"db_path": str(tmp_path / "x.db")})
bare.shutdown()
@@ -0,0 +1,243 @@
"""Tests for the holographic MemoryStore shared-connection registry.
MemoryStore instances pointing at the same database file must share one
process-wide SQLite connection and one re-entrant lock. Multiple providers
coexist in a single process (the main agent plus every delegate_task
subagent); when each instance owned a private connection they raced as
independent WAL writers and intermittently failed with "database is locked".
Covers: connection sharing/refcounting, close() semantics, cross-instance
visibility, concurrent multi-instance writers, and write-lock release after
a failed write.
"""
import sqlite3
import threading
import pytest
from plugins.memory.holographic.store import MemoryStore
@pytest.fixture(autouse=True)
def _clean_shared_registry():
"""Each test starts and ends with an empty shared-connection registry."""
# Drop any leakage from earlier tests in the same process.
for entry in list(MemoryStore._shared.values()):
try:
entry["conn"].close()
except sqlite3.Error:
pass
MemoryStore._shared.clear()
yield
leaked = list(MemoryStore._shared)
for entry in list(MemoryStore._shared.values()):
try:
entry["conn"].close()
except sqlite3.Error:
pass
MemoryStore._shared.clear()
assert not leaked, f"test leaked shared connections: {leaked}"
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "memory_store.db"
class TestSharedConnection:
def test_same_path_shares_one_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
try:
assert a._conn is b._conn
assert a._lock is b._lock
assert len(MemoryStore._shared) == 1
assert MemoryStore._shared[str(a.db_path)]["refs"] == 2
finally:
a.close()
b.close()
def test_different_paths_get_distinct_connections(self, tmp_path):
a = MemoryStore(tmp_path / "one.db")
b = MemoryStore(tmp_path / "two.db")
try:
assert a._conn is not b._conn
assert len(MemoryStore._shared) == 2
finally:
a.close()
b.close()
def test_symlinked_path_shares_connection(self, tmp_path):
"""A symlink to the same DB file must hit the same registry entry —
otherwise two connections to one file silently reintroduce the
multi-writer contention the registry exists to prevent."""
real_dir = tmp_path / "real"
real_dir.mkdir()
link_dir = tmp_path / "link"
link_dir.symlink_to(real_dir)
a = MemoryStore(real_dir / "memory_store.db")
b = MemoryStore(link_dir / "memory_store.db")
try:
assert a._conn is b._conn
assert len(MemoryStore._shared) == 1
finally:
a.close()
b.close()
def test_writes_visible_across_instances(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
try:
fact_id = a.add_fact("Hermes likes shared connections", category="test")
facts = b.list_facts(category="test")
assert [f["fact_id"] for f in facts] == [fact_id]
finally:
a.close()
b.close()
def test_schema_initialised_once_per_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path) # must not re-run schema init / WAL probe
try:
assert MemoryStore._shared[str(a.db_path)]["ready"] is True
b.add_fact("schema still works")
finally:
a.close()
b.close()
class TestCloseSemantics:
def test_closing_one_instance_keeps_sibling_alive(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
a.close()
try:
# The shared connection must survive the sibling's close().
fact_id = b.add_fact("survivor write")
assert fact_id > 0
finally:
b.close()
def test_last_close_releases_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
conn = a._conn
a.close()
b.close()
assert MemoryStore._shared == {}
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_close_is_idempotent(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
a.close()
a.close() # double close must not steal b's reference
try:
b.add_fact("still alive after double close")
assert MemoryStore._shared[str(b.db_path)]["refs"] == 1
finally:
b.close()
def test_context_manager_releases_reference(self, db_path):
with MemoryStore(db_path) as store:
store.add_fact("context managed")
assert MemoryStore._shared == {}
def test_reopen_after_full_close(self, db_path):
with MemoryStore(db_path) as store:
store.add_fact("first lifetime")
with MemoryStore(db_path) as store:
facts = store.list_facts()
assert [f["content"] for f in facts] == ["first lifetime"]
class TestConcurrency:
def test_concurrent_multi_instance_writers(self, db_path):
"""Many instances writing from many threads must never hit
'database is locked' — the failure mode of per-instance connections."""
n_threads, n_facts = 8, 15
errors: list[BaseException] = []
def writer(idx: int) -> None:
store = MemoryStore(db_path)
try:
for i in range(n_facts):
store.add_fact(f"fact thread={idx} seq={i}", category="load")
except BaseException as exc: # noqa: BLE001 - recorded for assert
errors.append(exc)
finally:
store.close()
threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"concurrent writers failed: {errors[:3]}"
with MemoryStore(db_path) as store:
facts = store.list_facts(category="load", limit=500)
assert len(facts) == n_threads * n_facts
assert MemoryStore._shared == {}
def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch):
"""A write that raises mid-method must not leave an open transaction
holding the SQLite write lock (autocommit isolation_level=None)."""
broken = MemoryStore(db_path)
sibling = MemoryStore(db_path)
try:
monkeypatch.setattr(
MemoryStore,
"_rebuild_bank",
lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")),
)
with pytest.raises(RuntimeError, match="boom"):
broken.add_fact("write that fails after the INSERT")
monkeypatch.undo()
# No dangling transaction: the connection reports autocommit state
# and the sibling can write immediately.
assert broken._conn.in_transaction is False
sibling.add_fact("sibling write right after the failure")
finally:
broken.close()
sibling.close()
class TestProviderShutdown:
"""The provider's shutdown() must release its shared connection, not just
drop the reference. Leaving finalization to GC keeps the connection (and
its write lock) alive on a long-running gateway, which is exactly the
"database is locked" contention the shared-connection registry removes."""
def test_shutdown_releases_shared_connection(self, db_path):
from plugins.memory.holographic import HolographicMemoryProvider
provider = HolographicMemoryProvider(config={"db_path": str(db_path)})
provider.initialize("session-shutdown")
assert MemoryStore._shared[str(db_path)]["refs"] == 1
provider.shutdown()
assert provider._store is None
assert MemoryStore._shared == {}
def test_shutdown_keeps_sibling_provider_alive(self, db_path):
from plugins.memory.holographic import HolographicMemoryProvider
a = HolographicMemoryProvider(config={"db_path": str(db_path)})
b = HolographicMemoryProvider(config={"db_path": str(db_path)})
a.initialize("session-a")
b.initialize("session-b")
assert MemoryStore._shared[str(db_path)]["refs"] == 2
a.shutdown()
# Sibling still holds a live, writable connection.
assert MemoryStore._shared[str(db_path)]["refs"] == 1
assert b._store is not None
b._store.add_fact("write after sibling shutdown")
b.shutdown()
assert MemoryStore._shared == {}
+304
View File
@@ -0,0 +1,304 @@
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
import pytest
from plugins.memory.mem0._backend import (
Mem0Backend,
PlatformBackend,
OSSBackend,
SelfHostedBackend,
)
class FakePlatformClient:
"""Fake MemoryClient for PlatformBackend tests."""
def __init__(self):
self.calls = []
def search(self, query, **kwargs):
self.calls.append(("search", query, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.9}]}
def get_all(self, **kwargs):
self.calls.append(("get_all", kwargs))
return {"count": 1, "next": None, "results": [{"id": "m1", "memory": "fact1"}]}
def add(self, messages, **kwargs):
self.calls.append(("add", messages, kwargs))
return {"status": "PENDING", "event_id": "evt-1"}
def update(self, **kwargs):
self.calls.append(("update", kwargs))
return {"id": kwargs["memory_id"], "text": kwargs["text"]}
def delete(self, **kwargs):
self.calls.append(("delete", kwargs))
class TestPlatformBackend:
def _make(self):
client = FakePlatformClient()
backend = PlatformBackend.__new__(PlatformBackend)
backend._client = client
return backend, client
def test_search_forwards_params(self):
backend, client = self._make()
result = backend.search("test query", filters={"user_id": "u1"}, top_k=5)
assert client.calls[0][0] == "search"
assert client.calls[0][1] == "test query"
assert client.calls[0][2]["filters"] == {"user_id": "u1"}
assert client.calls[0][2]["top_k"] == 5
def test_search_forwards_rerank(self):
backend, client = self._make()
backend.search("q", filters={}, rerank=False)
assert client.calls[0][2]["rerank"] is False
def test_search_rerank_default_false(self):
backend, client = self._make()
backend.search("q", filters={})
assert client.calls[0][2]["rerank"] is False
def test_search_returns_list(self):
backend, _ = self._make()
result = backend.search("q", filters={})
assert isinstance(result, list)
assert result[0]["id"] == "m1"
def test_add_forwards_kwargs(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
result = backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
call = client.calls[0]
assert call[2]["user_id"] == "u1"
assert call[2]["infer"] is False
# metadata kwarg should be omitted entirely when not provided so we
# don't surprise older mem0 client versions with an unknown kwarg.
assert "metadata" not in call[2]
def test_add_forwards_metadata_when_present(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
backend.add(
msgs,
user_id="u1",
agent_id="hermes",
infer=False,
metadata={"channel": "telegram"},
)
assert client.calls[0][2]["metadata"] == {"channel": "telegram"}
def test_add_omits_empty_metadata(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={})
assert "metadata" not in client.calls[0][2]
def test_update_forwards(self):
backend, client = self._make()
backend.update("m1", "new text")
assert client.calls[0][1] == {"memory_id": "m1", "text": "new text"}
def test_delete_forwards(self):
backend, client = self._make()
backend.delete("m1")
assert client.calls[0][1] == {"memory_id": "m1"}
class FakeOSSMemory:
"""Fake mem0.Memory for OSSBackend tests."""
def __init__(self):
self.calls = []
def search(self, query, **kwargs):
self.calls.append(("search", query, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.8}]}
def get_all(self, **kwargs):
self.calls.append(("get_all", kwargs))
return {"results": [{"id": "m1", "memory": "fact1"}]}
def add(self, messages, **kwargs):
self.calls.append(("add", messages, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "event": "ADD"}]}
def update(self, memory_id, **kwargs):
self.calls.append(("update", memory_id, kwargs))
return {"message": "Memory updated successfully!"}
def delete(self, memory_id):
self.calls.append(("delete", memory_id))
return {"message": "Memory deleted successfully!"}
class TestOSSBackend:
def _make(self):
memory = FakeOSSMemory()
backend = OSSBackend.__new__(OSSBackend)
backend._memory = memory
return backend, memory
def test_search_returns_list(self):
backend, _ = self._make()
result = backend.search("test", filters={"user_id": "u1"})
assert isinstance(result, list)
assert result[0]["id"] == "m1"
def test_search_passes_filters(self):
backend, memory = self._make()
backend.search("q", filters={"user_id": "u1"}, top_k=3)
assert memory.calls[0][2]["filters"] == {"user_id": "u1"}
assert memory.calls[0][2]["top_k"] == 3
def test_search_ignores_rerank(self):
"""OSS backend accepts rerank param but does not forward it to Memory."""
backend, memory = self._make()
backend.search("q", filters={}, rerank=True)
assert "rerank" not in memory.calls[0][2]
def test_add_forwards_kwargs(self):
backend, memory = self._make()
msgs = [{"role": "user", "content": "hi"}]
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
assert memory.calls[0][2]["user_id"] == "u1"
assert memory.calls[0][2]["infer"] is False
def test_update_maps_text_to_data(self):
"""OSS Memory.update uses `data=` param, not `text=`."""
backend, memory = self._make()
backend.update("m1", "new text")
assert memory.calls[0][0] == "update"
assert memory.calls[0][1] == "m1"
assert memory.calls[0][2] == {"data": "new text"}
def test_delete_positional_arg(self):
backend, memory = self._make()
backend.delete("m1")
assert memory.calls[0] == ("delete", "m1")
def test_update_normalizes_response(self):
backend, _ = self._make()
result = backend.update("m1", "text")
assert result == {"result": "Memory updated.", "memory_id": "m1"}
def test_delete_normalizes_response(self):
backend, _ = self._make()
result = backend.delete("m1")
assert result == {"result": "Memory deleted.", "memory_id": "m1"}
httpx = pytest.importorskip("httpx")
class _StubServer:
"""Records requests and serves the real self-hosted server's response shapes."""
def __init__(self, rows=10):
self.requests = []
self._rows = [{"id": f"m{i}", "memory": f"f{i}"} for i in range(rows)]
def handler(self, request):
self.requests.append(request)
path, method = request.url.path, request.method
if path == "/search" and method == "POST":
return httpx.Response(200, json={"results": [{"id": "m1", "memory": "tea", "score": 0.9}]})
if path == "/memories" and method == "GET":
top_k = int(request.url.params.get("top_k", len(self._rows)))
return httpx.Response(200, json={"results": self._rows[:top_k]})
if path == "/memories" and method == "POST":
return httpx.Response(200, json={"results": [{"id": "new", "memory": "stored", "event": "ADD"}]})
if path.startswith("/memories/") and method in ("PUT", "DELETE"):
if path.endswith("/missing"): # server 404s unknown ids
return httpx.Response(404, json={"detail": "Memory not found"})
verb = "updated" if method == "PUT" else "Memory deleted successfully"
return httpx.Response(200, json={"message": verb})
return httpx.Response(404, json={"detail": "not found"})
def _backend(server, api_key="adminkey", host="http://sh:8888"):
"""Build a SelfHostedBackend routed through the stub transport.
Uses the real __init__ (via the injectable ``transport`` kwarg) so the
constructor's header/base_url setup is exercised by every test here.
"""
return SelfHostedBackend(
api_key, host, transport=httpx.MockTransport(server.handler)
)
class TestSelfHostedBackend:
# --- constructor / auth setup (the crux of the bug) -------------------
def test_init_uses_x_api_key_not_token_auth(self):
b = SelfHostedBackend("adminkey", "http://sh:8888")
assert b._client.headers["x-api-key"] == "adminkey"
assert "authorization" not in b._client.headers # NOT the cloud 'Token' scheme
def test_init_strips_trailing_slash(self):
b = SelfHostedBackend("k", "http://sh:8888/")
assert str(b._client.base_url) == "http://sh:8888"
def test_init_omits_api_key_header_when_blank(self):
b = SelfHostedBackend("", "http://sh:8888") # AUTH_DISABLED server
assert "x-api-key" not in b._client.headers
# --- search ----------------------------------------------------------
def test_search_posts_to_search_with_filters_in_body(self):
s = _StubServer()
results = _backend(s).search("drink", filters={"user_id": "u1"}, top_k=5)
req = s.requests[-1]
assert (req.method, req.url.path) == ("POST", "/search")
import json
body = json.loads(req.content)
assert body == {"query": "drink", "top_k": 5, "filters": {"user_id": "u1"}}
assert results == [{"id": "m1", "memory": "tea", "score": 0.9}]
def test_search_sends_x_api_key_header(self):
s = _StubServer()
_backend(s).search("q", filters={"user_id": "u1"})
req = s.requests[-1]
assert req.headers["x-api-key"] == "adminkey"
assert "authorization" not in req.headers
# --- add / update / delete ------------------------------------------
def test_add_posts_messages_and_identity(self):
s = _StubServer()
msgs = [{"role": "user", "content": "likes tea"}]
result = _backend(s).add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={"channel": "cli"})
req = s.requests[-1]
assert (req.method, req.url.path) == ("POST", "/memories")
import json
body = json.loads(req.content)
assert body == {"messages": msgs, "user_id": "u1", "agent_id": "hermes",
"infer": False, "metadata": {"channel": "cli"}}
assert result["results"][0]["id"] == "new"
def test_update_puts_text_to_memory_id(self):
s = _StubServer()
result = _backend(s).update("abc", "new text")
req = s.requests[-1]
assert (req.method, req.url.path) == ("PUT", "/memories/abc")
import json
assert json.loads(req.content) == {"text": "new text"}
assert result == {"result": "Memory updated.", "memory_id": "abc"}
def test_delete_calls_delete_endpoint(self):
s = _StubServer()
result = _backend(s).delete("abc")
req = s.requests[-1]
assert (req.method, req.url.path) == ("DELETE", "/memories/abc")
assert result == {"result": "Memory deleted.", "memory_id": "abc"}
# --- error propagation (feeds the plugin's circuit breaker) ----------
def test_http_error_raises(self):
s = _StubServer()
with pytest.raises(httpx.HTTPStatusError):
_backend(s).delete("missing") # 404 -> raise_for_status; 'not found' won't trip breaker
+107
View File
@@ -0,0 +1,107 @@
"""Tests for OSS provider definitions and validation."""
import pytest
from plugins.memory.mem0._oss_providers import (
LLM_PROVIDERS,
EMBEDDER_PROVIDERS,
VECTOR_PROVIDERS,
KNOWN_DIMS,
validate_oss_config,
)
class TestProviderDefinitions:
def test_llm_providers_have_required_keys(self):
for pid, p in LLM_PROVIDERS.items():
assert "label" in p
assert "needs_key" in p
assert "default_model" in p
def test_embedder_providers_have_required_keys(self):
for pid, p in EMBEDDER_PROVIDERS.items():
assert "label" in p
assert "needs_key" in p
assert "default_model" in p
assert "dims" in p
def test_embedder_provider_ids(self):
assert set(EMBEDDER_PROVIDERS.keys()) == {"openai", "ollama"}
def test_vector_providers_have_required_keys(self):
for pid, p in VECTOR_PROVIDERS.items():
assert "label" in p
assert "default_config" in p
def test_vector_provider_ids(self):
assert set(VECTOR_PROVIDERS.keys()) == {"qdrant", "pgvector"}
def test_known_dims_covers_defaults(self):
for pid, p in EMBEDDER_PROVIDERS.items():
assert p["default_model"] in KNOWN_DIMS
class TestValidation:
def test_valid_openai_config(self):
cfg = {
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {"path": "/tmp/test"}},
}
errors = validate_oss_config(cfg)
assert errors == []
def test_unknown_llm_provider(self):
cfg = {
"llm": {"provider": "gemini", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
errors = validate_oss_config(cfg)
assert any("llm" in e.lower() for e in errors)
def test_unknown_embedder_provider(self):
cfg = {
"llm": {"provider": "openai", "config": {}},
"embedder": {"provider": "cohere", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
errors = validate_oss_config(cfg)
assert any("embedder" in e.lower() for e in errors)
def test_unknown_vector_provider(self):
cfg = {
"llm": {"provider": "openai", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "redis", "config": {}},
}
errors = validate_oss_config(cfg)
assert any("vector" in e.lower() for e in errors)
def test_missing_llm_section(self):
cfg = {
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
errors = validate_oss_config(cfg)
assert any("llm" in e.lower() for e in errors)
def test_pgvector_needs_user(self):
cfg = {
"llm": {"provider": "openai", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "pgvector", "config": {"host": "localhost"}},
}
errors = validate_oss_config(cfg)
assert any("user" in e.lower() for e in errors)
def test_pgvector_with_user_valid(self):
cfg = {
"llm": {"provider": "openai", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "pgvector", "config": {"host": "localhost", "user": "pg"}},
}
errors = validate_oss_config(cfg)
assert errors == []
+313
View File
@@ -0,0 +1,313 @@
"""Tests for Mem0 setup wizard — flag parsing, config building, validation."""
import json
import sys
import types
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from plugins.memory.mem0._setup import (
parse_flags,
build_oss_config,
_write_env,
post_setup,
_check_qdrant_path,
_check_ollama,
_check_pgvector,
)
def _inject_fake_hermes_cli(monkeypatch):
"""Inject fake hermes_cli modules so yaml/curses aren't required."""
fake_config_mod = types.ModuleType("hermes_cli.config")
fake_config_mod.save_config = lambda c: None
fake_setup_mod = types.ModuleType("hermes_cli.memory_setup")
fake_setup_mod._curses_select = lambda *a, **kw: 0
fake_setup_mod._prompt = lambda label, default=None, secret=False: default or ""
fake_hermes_cli = types.ModuleType("hermes_cli")
fake_hermes_cli.config = fake_config_mod
fake_hermes_cli.memory_setup = fake_setup_mod
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.config", fake_config_mod)
monkeypatch.setitem(sys.modules, "hermes_cli.memory_setup", fake_setup_mod)
monkeypatch.setattr("plugins.memory.mem0._setup._curses_select", lambda *a, **kw: 0)
monkeypatch.setattr("plugins.memory.mem0._setup._prompt", lambda label, default=None, secret=False: default or "")
return fake_config_mod
class TestParseFlags:
def test_mode_platform(self):
flags = parse_flags(["--mode", "platform", "--api-key", "sk-test"])
assert flags["mode"] == "platform"
assert flags["api_key"] == "sk-test"
def test_mode_oss_defaults(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
assert flags["mode"] == "oss"
assert flags["oss_llm"] == "openai"
assert flags["oss_embedder"] == "openai"
assert flags["oss_vector"] == "qdrant"
def test_mode_oss_all_flags(self):
flags = parse_flags([
"--mode", "oss",
"--oss-llm", "ollama",
"--oss-llm-model", "llama3:latest",
"--oss-embedder", "ollama",
"--oss-embedder-model", "nomic-embed-text",
"--oss-vector", "pgvector",
"--oss-vector-host", "db.local",
"--oss-vector-port", "5433",
"--oss-vector-user", "pguser",
"--oss-vector-password", "secret",
"--oss-vector-dbname", "memdb",
"--user-id", "my-user",
])
assert flags["oss_llm"] == "ollama"
assert flags["oss_llm_model"] == "llama3:latest"
assert flags["oss_vector"] == "pgvector"
assert flags["oss_vector_user"] == "pguser"
assert flags["user_id"] == "my-user"
def test_no_flags_returns_empty_mode(self):
flags = parse_flags([])
assert flags["mode"] == ""
def test_oss_vector_path_flag(self):
flags = parse_flags(["--mode", "oss", "--oss-vector-path", "/data/qdrant"])
assert flags["oss_vector_path"] == "/data/qdrant"
class TestBuildOSSConfig:
def test_openai_defaults(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
oss, env_writes = build_oss_config(flags)
assert oss["llm"]["provider"] == "openai"
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
assert oss["embedder"]["provider"] == "openai"
assert oss["embedder"]["config"]["model"] == "text-embedding-3-small"
assert oss["vector_store"]["provider"] == "qdrant"
assert env_writes["OPENAI_API_KEY"] == "sk-oai"
def test_ollama_no_key_needed(self):
flags = parse_flags(["--mode", "oss", "--oss-llm", "ollama", "--oss-embedder", "ollama"])
oss, env_writes = build_oss_config(flags)
assert oss["llm"]["provider"] == "ollama"
assert "model" in oss["llm"]["config"]
assert env_writes == {}
def test_embedder_reuses_llm_key(self):
"""When LLM and embedder share same provider, key written once."""
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
_, env_writes = build_oss_config(flags)
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
def test_different_embedder_needs_separate_key(self):
flags = parse_flags([
"--mode", "oss",
"--oss-llm", "ollama",
"--oss-embedder", "openai", "--oss-embedder-key", "sk-oai",
])
_, env_writes = build_oss_config(flags)
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
def test_pgvector_config(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-vector", "pgvector",
"--oss-vector-host", "db.local", "--oss-vector-port", "5433",
"--oss-vector-user", "pg", "--oss-vector-dbname", "memdb",
])
oss, _ = build_oss_config(flags)
vs = oss["vector_store"]
assert vs["provider"] == "pgvector"
assert vs["config"]["host"] == "db.local"
assert vs["config"]["port"] == 5433
assert vs["config"]["user"] == "pg"
def test_known_dims_auto_set(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
oss, _ = build_oss_config(flags)
dims = oss["embedder"]["config"].get("embedding_dims")
assert dims == 1536
def test_custom_qdrant_path(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-vector-path", "/data/qdrant",
])
oss, _ = build_oss_config(flags)
assert oss["vector_store"]["config"]["path"] == "/data/qdrant"
class TestWriteEnv:
def test_write_new_vars(self, tmp_path):
env_path = tmp_path / ".env"
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
content = env_path.read_text()
assert "OPENAI_API_KEY=sk-test" in content
def test_update_existing_var(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_text("OPENAI_API_KEY=old\nOTHER=keep\n")
_write_env(env_path, {"OPENAI_API_KEY": "new"})
content = env_path.read_text()
assert "OPENAI_API_KEY=new" in content
assert "OTHER=keep" in content
assert "old" not in content
class TestPostSetup:
def test_platform_flag_mode(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "mem0"
env_content = (tmp_path / ".env").read_text()
assert "MEM0_API_KEY=sk-test" in env_content
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["mode"] == "platform"
def test_platform_setup_clears_stale_host(self, tmp_path, monkeypatch):
# A user who previously ran self-hosted has host in mem0.json. Switching
# to platform must drop host — otherwise routing (host > platform) keeps
# sending them to the self-hosted server despite --mode platform.
(tmp_path / "mem0.json").write_text(
json.dumps({"mode": "platform", "host": "http://old-selfhosted:8888"})
)
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
config = {"memory": {}}
post_setup(str(tmp_path), config)
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["mode"] == "platform"
assert not mem0_json.get("host") # cleared to falsy so routing → platform
def test_oss_flag_mode(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", [
"hermes", "--mode", "oss", "--oss-llm-key", "sk-oai",
])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
monkeypatch.setattr("plugins.memory.mem0._setup._install_provider_deps", lambda l, e, v: None)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "mem0"
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["mode"] == "oss"
assert mem0_json["oss"]["llm"]["provider"] == "openai"
def test_selfhosted_flag_mode(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", [
"hermes", "--mode", "selfhosted",
"--host", "http://localhost:8888/", "--api-key", "admin-key",
])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "mem0"
env_content = (tmp_path / ".env").read_text()
assert "MEM0_API_KEY=admin-key" in env_content
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["host"] == "http://localhost:8888" # trailing slash stripped
assert mem0_json["user_id"] == "hermes-user"
def test_selfhosted_no_api_key_auth_disabled(self, tmp_path, monkeypatch):
# AUTH_DISABLED servers need no key — setup must not write one.
monkeypatch.setattr("sys.argv", [
"hermes", "--mode", "self-hosted", "--host", "http://mem0.lan:8888",
])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
monkeypatch.delenv("MEM0_API_KEY", raising=False)
_inject_fake_hermes_cli(monkeypatch)
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert not (tmp_path / ".env").exists()
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["host"] == "http://mem0.lan:8888"
def test_selfhosted_dry_run_no_files(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", [
"hermes", "--mode", "selfhosted",
"--host", "http://localhost:8888", "--api-key", "k", "--dry-run",
])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert not (tmp_path / ".env").exists()
assert not (tmp_path / "mem0.json").exists()
assert "provider" not in config["memory"]
class TestDryRun:
def test_dry_run_flag_parsed(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run"])
assert flags["dry_run"] is True
def test_dry_run_not_set_by_default(self):
flags = parse_flags(["--mode", "oss"])
assert flags["dry_run"] is False
def test_dry_run_platform_no_files(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test", "--dry-run"])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert not (tmp_path / ".env").exists()
assert not (tmp_path / "mem0.json").exists()
assert "provider" not in config["memory"]
def test_dry_run_oss_no_files(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", [
"hermes", "--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run",
])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
monkeypatch.setattr("plugins.memory.mem0._setup._install_provider_deps", lambda l, e, v: None)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert not (tmp_path / ".env").exists()
assert not (tmp_path / "mem0.json").exists()
assert "provider" not in config["memory"]
class TestConnectivityChecks:
def test_qdrant_path_writable(self, tmp_path):
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
assert ok is True
def test_qdrant_path_not_writable(self, tmp_path, monkeypatch):
def _raise_oserror(*a, **kw):
raise OSError("Permission denied")
monkeypatch.setattr(Path, "mkdir", _raise_oserror)
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
assert ok is False
assert "Permission denied" in msg
def test_ollama_unreachable(self):
ok, msg = _check_ollama("http://localhost:1")
assert ok is False
def test_pgvector_unreachable(self):
ok, msg = _check_pgvector("localhost", 1)
assert ok is False
+621
View File
@@ -0,0 +1,621 @@
"""Tests for Mem0 v3 API — new tool names, paginated responses, update/delete tools."""
import json
import time
import pytest
import plugins.memory.mem0 as mem0_plugin
from plugins.memory.mem0 import Mem0MemoryProvider
class FakeBackend:
"""Fake Mem0Backend for provider-level tests."""
def __init__(self, search_results=None, all_results=None):
self._search_results = search_results or []
self._all_results = all_results or {"results": [], "count": 0}
self.captured = []
def search(self, query, *, filters, top_k=10, rerank=True):
self.captured.append(("search", query, {"filters": filters, "top_k": top_k, "rerank": rerank}))
return self._search_results
def get_all(self, *, filters, page=1, page_size=100):
self.captured.append(("get_all", {"filters": filters, "page": page, "page_size": page_size}))
return self._all_results
def add(self, messages, *, user_id, agent_id, infer=False, metadata=None):
self.captured.append((
"add",
messages,
{"user_id": user_id, "agent_id": agent_id, "infer": infer, "metadata": metadata},
))
return {"status": "PENDING", "event_id": "evt-test-123"}
def update(self, memory_id, text):
self.captured.append(("update", memory_id, text))
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id):
self.captured.append(("delete", memory_id))
return {"result": "Memory deleted.", "memory_id": memory_id}
class TestMem0V3Tools:
"""Test v3 tool names and response handling."""
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_search_returns_ids(self, monkeypatch):
backend = FakeBackend(search_results=[{"id": "mem-1", "memory": "foo", "score": 0.9}])
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_search", {"query": "test"}))
assert result["results"][0]["id"] == "mem-1"
def test_search_uses_filters(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
provider.handle_tool_call("mem0_search", {"query": "hello", "top_k": 3})
assert backend.captured[0][2]["filters"] == {"user_id": "u123"}
assert backend.captured[0][2]["top_k"] == 3
def test_search_rerank_default_false(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
provider.handle_tool_call("mem0_search", {"query": "test"})
assert backend.captured[0][2]["rerank"] is False
def test_search_rerank_override_false(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
provider.handle_tool_call("mem0_search", {"query": "test", "rerank": False})
assert backend.captured[0][2]["rerank"] is False
def test_search_rerank_config_default_used_when_arg_absent(self, monkeypatch):
"""The persisted mem0.json ``rerank`` preference is the tool default;
a per-call arg still wins (see the explicit-False override above)."""
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
provider._rerank_default = True # as initialize() sets from config
provider.handle_tool_call("mem0_search", {"query": "test"})
assert backend.captured[0][2]["rerank"] is True
def test_add_uses_content_param(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"}))
assert len(backend.captured) == 1
call = backend.captured[0]
assert call[2]["infer"] is False
assert call[2]["user_id"] == "u123"
assert call[2]["agent_id"] == "hermes"
assert "event_id" in result
def test_add_returns_event_id(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "test"}))
assert result["event_id"] == "evt-test-123"
def test_add_missing_content(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_add", {}))
assert "error" in result
def test_old_tool_names_return_unknown(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_profile", {}))
assert "error" in result
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
assert "error" in result
class TestMem0UpdateDelete:
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_update_calls_sdk(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_update", {"memory_id": "mem-1", "text": "updated fact"}
))
assert backend.captured[0][1] == "mem-1"
assert backend.captured[0][2] == "updated fact"
assert result["result"] == "Memory updated."
assert result["memory_id"] == "mem-1"
def test_update_missing_memory_id(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_update", {"text": "no id"}))
assert "error" in result
def test_update_missing_text(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_update", {"memory_id": "mem-1"}))
assert "error" in result
def test_delete_calls_sdk(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_delete", {"memory_id": "mem-1"}
))
assert backend.captured[0][1] == "mem-1"
assert result["result"] == "Memory deleted."
def test_delete_missing_memory_id(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_delete", {}))
assert "error" in result
class TestMem0ErrorHandling:
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_update_404_no_circuit_breaker(self, monkeypatch):
backend = FakeBackend()
backend.update = lambda mid, text: (_ for _ in ()).throw(Exception("404 Not Found"))
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_update", {"memory_id": "bad-id", "text": "x"}
))
assert "error" in result
assert provider._consecutive_failures == 0
def test_delete_404_no_circuit_breaker(self, monkeypatch):
backend = FakeBackend()
backend.delete = lambda mid: (_ for _ in ()).throw(Exception("404 not found"))
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_delete", {"memory_id": "bad-id"}
))
assert "error" in result
assert provider._consecutive_failures == 0
def test_update_validation_error_no_circuit_breaker(self, monkeypatch):
"""ValidationError (bad UUID format) should not trip circuit breaker."""
class ValidationError(Exception):
pass
backend = FakeBackend()
backend.update = lambda mid, text: (_ for _ in ()).throw(
ValidationError('{"error":"memory_id should be a valid UUID"}')
)
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_update", {"memory_id": "not-a-uuid", "text": "x"}
))
assert "error" in result
assert provider._consecutive_failures == 0
def test_delete_validation_error_no_circuit_breaker(self, monkeypatch):
class ValidationError(Exception):
pass
backend = FakeBackend()
backend.delete = lambda mid: (_ for _ in ()).throw(
ValidationError('{"error":"memory_id should be a valid UUID"}')
)
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_delete", {"memory_id": "not-a-uuid"}
))
assert "error" in result
assert provider._consecutive_failures == 0
def test_update_5xx_trips_circuit_breaker(self, monkeypatch):
backend = FakeBackend()
backend.update = lambda mid, text: (_ for _ in ()).throw(Exception("500 Internal Server Error"))
provider = self._make_provider(monkeypatch, backend)
provider.handle_tool_call("mem0_update", {"memory_id": "mem-1", "text": "x"})
assert provider._consecutive_failures == 1
class TestMem0V3Internal:
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_sync_turn_explicit_kwargs(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
provider.sync_turn("user said", "assistant replied", session_id="s1")
provider._sync_thread.join(timeout=2)
assert len(backend.captured) == 1
call = backend.captured[0]
assert call[2]["user_id"] == "u123"
assert call[2]["agent_id"] == "hermes"
assert call[2]["infer"] is True
def test_old_tool_names_return_unknown(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_profile", {}))
assert "error" in result
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
assert "error" in result
result = json.loads(provider.handle_tool_call("mem0_list", {}))
assert "error" in result
class TestMem0Prefetch:
"""prefetch() must recall on the CURRENT question, synchronously.
The old implementation ignored its ``query`` and returned whatever a
background ``queue_prefetch`` had warmed from the PREVIOUS turn — so the
first turn injected nothing and later turns injected stale, off-topic
memories. These lock the corrected behaviour.
"""
def _make_provider(self, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_prefetch_searches_current_query(self):
backend = FakeBackend(search_results=[{"id": "m1", "memory": "user prefers dark mode"}])
provider = self._make_provider(backend)
result = provider.prefetch("what theme do I like?")
kind, query, opts = backend.captured[0]
assert kind == "search"
assert query == "what theme do I like?"
assert opts["filters"] == {"user_id": "u123"}
assert opts["top_k"] == 10
assert opts["rerank"] is False
assert "## Mem0 Memory" in result
assert "user prefers dark mode" in result
def test_prefetch_returns_memories_on_first_call(self):
# No prior queue_prefetch / warm — the very first call must still recall.
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
provider = self._make_provider(backend)
result = provider.prefetch("where do I live?")
assert "lives in Berlin" in result
def test_on_turn_start_queues_current_query(self):
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
provider = self._make_provider(backend)
provider.on_turn_start(1, "where do I live?")
provider._prefetch_thread.join(timeout=1)
result = provider.prefetch("where do I live?")
assert "lives in Berlin" in result
assert len([c for c in backend.captured if c[0] == "search"]) == 1
def test_slow_prefetch_returns_quickly(self, monkeypatch):
class SlowBackend(FakeBackend):
def search(self, query, *, filters, top_k=10, rerank=True):
time.sleep(0.2)
return super().search(query, filters=filters, top_k=top_k, rerank=rerank)
monkeypatch.setattr(mem0_plugin, "_PREFETCH_WAIT_SECS", 0.01)
provider = self._make_provider(
SlowBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
)
started = time.monotonic()
assert provider.prefetch("where do I live?") == ""
assert time.monotonic() - started < 0.1
provider._prefetch_thread.join(timeout=1)
assert "lives in Berlin" in provider.prefetch("where do I live?")
def test_prefetch_empty_results_returns_empty(self):
backend = FakeBackend(search_results=[])
provider = self._make_provider(backend)
assert provider.prefetch("anything") == ""
def test_prefetch_skips_when_breaker_open(self):
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
provider = self._make_provider(backend)
provider._consecutive_failures = 5
provider._breaker_open_until = float("inf")
assert provider.prefetch("q") == ""
assert backend.captured == []
def test_queue_prefetch_fires_no_search(self):
# prefetch is synchronous now, so the post-turn warm is redundant and
# must not fire a wasted backend search.
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
provider = self._make_provider(backend)
provider.queue_prefetch("previous turn text")
assert backend.captured == []
class TestMem0V3Config:
def test_tool_schemas_four_tools(self):
provider = Mem0MemoryProvider()
schemas = provider.get_tool_schemas()
names = [s["name"] for s in schemas]
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
def test_system_prompt_new_tool_names(self):
provider = Mem0MemoryProvider()
provider._user_id = "test"
block = provider.system_prompt_block()
assert "mem0_search" in block
assert "mem0_add" in block
assert "mem0_update" in block
assert "mem0_delete" in block
assert "mem0_list" not in block
assert "mem0_profile" not in block
assert "mem0_conclude" not in block
def test_system_prompt_shows_platform_mode(self):
provider = Mem0MemoryProvider()
provider._user_id = "test"
provider._mode = "platform"
block = provider.system_prompt_block()
assert "platform" in block
assert "Rerank" in block
def test_system_prompt_shows_oss_mode(self):
provider = Mem0MemoryProvider()
provider._user_id = "test"
provider._mode = "oss"
block = provider.system_prompt_block()
assert "OSS" in block
assert "Rerank" not in block
def test_search_schema_has_rerank(self):
"""rerank property available in SEARCH_SCHEMA for platform mode."""
provider = Mem0MemoryProvider()
schemas = provider.get_tool_schemas()
search = next(s for s in schemas if s["name"] == "mem0_search")
assert "rerank" in search["parameters"]["properties"]
assert search["parameters"]["properties"]["rerank"]["type"] == "boolean"
class TestMem0ModeSwitch:
def test_default_mode_is_platform(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("MEM0_API_KEY", "test-key")
provider = Mem0MemoryProvider()
provider.initialize("test")
assert provider._mode == "platform"
def test_missing_mode_key_defaults_platform(self, monkeypatch, tmp_path):
"""Backward compat: old mem0.json without mode key works."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "mem0.json"
config_path.write_text('{"user_id": "old-user"}')
monkeypatch.setenv("MEM0_API_KEY", "test-key")
provider = Mem0MemoryProvider()
provider.initialize("test")
assert provider._mode == "platform"
assert provider._user_id == "old-user"
def test_is_available_platform_needs_key(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MEM0_API_KEY", raising=False)
provider = Mem0MemoryProvider()
assert provider.is_available() is False
def test_is_available_oss_needs_vector(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "mem0.json"
config_path.write_text('{"mode": "oss", "oss": {"vector_store": {"provider": "qdrant"}}}')
provider = Mem0MemoryProvider()
assert provider.is_available() is True
def test_is_available_oss_no_vector(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "mem0.json"
config_path.write_text('{"mode": "oss", "oss": {}}')
provider = Mem0MemoryProvider()
assert provider.is_available() is False
def test_tool_schemas_unchanged(self):
provider = Mem0MemoryProvider()
schemas = provider.get_tool_schemas()
names = [s["name"] for s in schemas]
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
def test_system_prompt_includes_mode(self):
provider = Mem0MemoryProvider()
provider._user_id = "test"
provider._mode = "oss"
block = provider.system_prompt_block()
assert "mem0_search" in block
assert "OSS" in block
class TestMem0UserIdResolution:
"""user_id resolution: configured override > gateway-native id > placeholder.
Same human across CLI / Telegram / Discord / Slack / etc. should map to
the same memory store when MEM0_USER_ID is set, and only fall back to the
gateway-native id when it isn't.
"""
def _provider(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("MEM0_API_KEY", "test-key")
provider = Mem0MemoryProvider()
# Skip backend instantiation — we only care about identity resolution.
provider._create_backend = lambda: None # type: ignore[method-assign]
return provider
def test_env_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
monkeypatch.setenv("MEM0_USER_ID", "ryan@example.com")
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "ryan@example.com"
def test_file_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
monkeypatch.delenv("MEM0_USER_ID", raising=False)
(tmp_path / "mem0.json").write_text('{"user_id": "ryan@example.com"}')
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "ryan@example.com"
def test_unset_falls_back_to_gateway_native_id(self, monkeypatch, tmp_path):
monkeypatch.delenv("MEM0_USER_ID", raising=False)
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "123456789"
def test_unset_and_no_kwargs_falls_back_to_default(self, monkeypatch, tmp_path):
monkeypatch.delenv("MEM0_USER_ID", raising=False)
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test")
assert provider._user_id == "hermes-user"
def test_legacy_placeholder_in_config_does_not_override_kwargs(self, monkeypatch, tmp_path):
# Setup wizard historically wrote {"user_id": "hermes-user"} as the
# suggested default. Treat that placeholder as unset so users on
# gateways still get gateway-native ids — not silent collisions.
monkeypatch.delenv("MEM0_USER_ID", raising=False)
(tmp_path / "mem0.json").write_text('{"user_id": "hermes-user"}')
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "123456789"
class TestMem0WriteMetadata:
"""Writes carry metadata.channel so per-channel filtered views are possible
without coupling identity to the channel.
"""
def _make_provider(self, channel: str = "cli"):
provider = Mem0MemoryProvider()
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._channel = channel
provider._backend = FakeBackend()
return provider
def test_add_tool_passes_channel_metadata(self):
provider = self._make_provider("telegram")
provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"})
call = provider._backend.captured[-1]
assert call[2]["metadata"] == {"channel": "telegram"}
def test_sync_turn_passes_channel_metadata(self):
provider = self._make_provider("discord")
provider.sync_turn("hi", "hello", session_id="s")
# sync_turn fires a daemon thread; wait for it.
if provider._sync_thread:
provider._sync_thread.join(timeout=5.0)
adds = [c for c in provider._backend.captured if c[0] == "add"]
assert adds, "expected an add call from sync_turn"
assert adds[-1][2]["metadata"] == {"channel": "discord"}
class _SentinelBackend:
def __init__(self, *args):
self.args = args
class TestCreateBackendRouting:
"""_create_backend() must pick the backend matching the configured mode/host."""
def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""):
# Neutralize lazy-install so the routing decision is all we exercise.
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False)
provider = Mem0MemoryProvider()
provider._mode = mode
provider._api_key = api_key
provider._host = host
provider._config = {"oss": {"vector_store": {"provider": "qdrant"}}}
return provider
def test_routes_to_selfhosted_when_host_set(self, monkeypatch):
captured = {}
class SH(_SentinelBackend):
def __init__(self, api_key, host):
captured["args"] = (api_key, host)
monkeypatch.setattr("plugins.memory.mem0._backend.SelfHostedBackend", SH)
provider = self._provider(monkeypatch, host="http://sh:8888", api_key="adminkey")
backend = provider._create_backend()
assert isinstance(backend, SH)
assert captured["args"] == ("adminkey", "http://sh:8888")
def test_routes_to_platform_when_no_host(self, monkeypatch):
class PB(_SentinelBackend):
def __init__(self, api_key):
pass
monkeypatch.setattr("plugins.memory.mem0._backend.PlatformBackend", PB)
provider = self._provider(monkeypatch, host="")
assert isinstance(provider._create_backend(), PB)
def test_routes_to_oss_when_mode_oss(self, monkeypatch):
class OB(_SentinelBackend):
def __init__(self, cfg):
pass
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
provider = self._provider(monkeypatch, mode="oss")
assert isinstance(provider._create_backend(), OB)
def test_oss_mode_takes_precedence_over_host(self, monkeypatch):
class OB(_SentinelBackend):
def __init__(self, cfg):
pass
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
assert isinstance(provider._create_backend(), OB)
def test_prompt_label_matches_routing_when_oss_and_host_both_set(self, monkeypatch):
# system_prompt_block must mirror _create_backend precedence: with both
# mode=oss and host set, OSS wins the routing, so the prompt must label
# OSS — not "self-hosted (HTTP API)". Guards the prompt-vs-routing lie.
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
provider._user_id = "test"
block = provider.system_prompt_block()
assert "OSS" in block
assert "HTTP API" not in block
class TestSelfHostedConfig:
"""Config plumbing for self-hosted (MEM0_HOST env + is_available)."""
def test_load_config_reads_mem0_host_env(self, monkeypatch):
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
assert mem0_plugin._load_config()["host"] == "http://localhost:8888"
def test_is_available_true_with_host_only(self, monkeypatch):
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.setenv("MEM0_MODE", "platform")
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
assert Mem0MemoryProvider().is_available() is True
def test_is_available_false_without_key_or_host(self, monkeypatch):
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("MEM0_HOST", raising=False)
monkeypatch.setenv("MEM0_MODE", "platform")
assert Mem0MemoryProvider().is_available() is False
@@ -0,0 +1,254 @@
"""Regression tests: supermemory + mem0 memory providers must lazy-install
their SDKs like honcho/hindsight.
Both providers ship a third-party SDK (``supermemory`` / ``mem0ai``) that is
NOT a core dependency. Before this fix they imported the SDK directly with no
``tools.lazy_deps.ensure()`` preflight and had no ``LAZY_DEPS`` allowlist
entry. On the published Docker image the agent venv is sealed
(``HERMES_DISABLE_LAZY_INSTALLS=1``) and lazy installs are redirected to a
writable durable target (``HERMES_LAZY_INSTALL_TARGET``). honcho/hindsight
route through ``ensure()`` and therefore install fine on a hosted instance;
supermemory/mem0 never called it, so the SDK was never installed there and
the provider silently reported itself unavailable.
These tests pin the contract:
1. Both features are in the ``LAZY_DEPS`` allowlist (without an entry,
``ensure()`` raises ``FeatureUnavailable`` — the original silent-dark bug).
2. Each provider's SDK-import chokepoint actually calls ``ensure(<feature>)``.
3. supermemory's ``is_available()`` no longer gates on the SDK being
importable (the chicken-and-egg trap that stopped the provider loading at
all on a sealed venv, so ``initialize()``/``ensure()`` never ran).
4. The real sealed-venv durable-target gate accepts the new features (the
exact hosted-Fly condition the user hit).
The pip subprocess is never actually run — ``_venv_pip_install`` /
``_is_satisfied`` are stubbed so we exercise the real ``ensure()`` control
flow without touching PyPI.
"""
from __future__ import annotations
import os
import pytest
import tools.lazy_deps as ld
MEMORY_FEATURES = ("memory.supermemory", "memory.mem0")
# ---------------------------------------------------------------------------
# 1. Allowlist contract — the core regression.
# ---------------------------------------------------------------------------
class TestAllowlistEntries:
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_feature_is_allowlisted(self, feature):
# Without an allowlist entry, ensure() raises FeatureUnavailable with
# "not in LAZY_DEPS" — which is exactly why the SDK never installed on
# a hosted instance before this fix.
assert feature in ld.LAZY_DEPS, (
f"{feature!r} missing from LAZY_DEPS — its SDK can never "
f"lazy-install on a sealed Docker venv."
)
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_feature_specs_pass_safety(self, feature):
for spec in ld.LAZY_DEPS[feature]:
assert ld._spec_is_safe(spec), f"{feature}: {spec!r} fails safety"
def test_supermemory_spec_package(self):
specs = ld.LAZY_DEPS["memory.supermemory"]
assert any(ld._pkg_name_from_spec(s) == "supermemory" for s in specs)
def test_mem0_spec_package(self):
# mem0's pip package is ``mem0ai`` (imports as ``mem0``).
specs = ld.LAZY_DEPS["memory.mem0"]
assert any(ld._pkg_name_from_spec(s) == "mem0ai" for s in specs)
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_unknown_feature_would_raise_without_entry(self, feature, monkeypatch):
# Demonstrate the failure mode the allowlist entry prevents: a feature
# NOT in LAZY_DEPS raises rather than installing.
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
ld.ensure(feature + ".typo", prompt=False)
# ---------------------------------------------------------------------------
# 2. Import sites call ensure().
# ---------------------------------------------------------------------------
class TestSupermemoryEnsureCalled:
def test_client_construction_calls_ensure(self, monkeypatch):
"""_SupermemoryClient.__init__ must call ensure('memory.supermemory')
before importing the SDK."""
from plugins.memory.supermemory import _SupermemoryClient
calls = []
monkeypatch.setattr(
ld, "ensure",
lambda feature, **kw: calls.append((feature, kw)),
)
# Stub the SDK so construction doesn't need the real package. The
# client does ``from supermemory import Supermemory`` right after
# ensure(); inject a fake module.
import sys
import types
fake = types.ModuleType("supermemory")
fake.Supermemory = lambda **kw: object()
monkeypatch.setitem(sys.modules, "supermemory", fake)
_SupermemoryClient(api_key="k", timeout=5.0, container_tag="hermes")
assert ("memory.supermemory", {"prompt": False}) in calls, (
"supermemory client did not call ensure('memory.supermemory', "
f"prompt=False); calls={calls}"
)
class TestMem0EnsureCalled:
def test_create_backend_calls_ensure(self, monkeypatch):
"""SupermemoryMemoryProvider-style mem0 provider must call
ensure('memory.mem0') in _create_backend before importing the SDK."""
from plugins.memory.mem0 import Mem0MemoryProvider
calls = []
monkeypatch.setattr(
ld, "ensure",
lambda feature, **kw: calls.append((feature, kw)),
)
prov = Mem0MemoryProvider()
# Platform mode is the default; force a known mode and stub the backend
# import so we isolate the ensure() call.
prov._mode = "platform"
prov._api_key = "k"
import sys
import types
fake = types.ModuleType("mem0")
fake.MemoryClient = lambda **kw: object()
fake.Memory = object
monkeypatch.setitem(sys.modules, "mem0", fake)
# _backend imports ``from mem0 import MemoryClient`` lazily inside
# PlatformBackend.__init__, so the fake module satisfies it.
prov._create_backend()
assert ("memory.mem0", {"prompt": False}) in calls, (
f"mem0 _create_backend did not call ensure('memory.mem0', "
f"prompt=False); calls={calls}"
)
# ---------------------------------------------------------------------------
# 3. supermemory is_available() chicken-and-egg fix.
# ---------------------------------------------------------------------------
class TestSupermemoryIsAvailable:
def test_available_with_key_even_when_sdk_absent(self, monkeypatch):
"""With the key set but the SDK not importable, is_available() must
still return True — otherwise the provider never loads on a sealed
venv and ensure() (which installs the SDK) never runs."""
from plugins.memory.supermemory import SupermemoryMemoryProvider
import builtins
monkeypatch.setenv("SUPERMEMORY_API_KEY", "sk-test")
# Make any attempt to import the SDK fail, simulating the
# not-yet-installed sealed-venv state.
real_import = builtins.__import__
def _no_supermemory(name, *args, **kwargs):
if name == "supermemory" or name.startswith("supermemory."):
raise ImportError("No module named 'supermemory'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _no_supermemory)
prov = SupermemoryMemoryProvider()
assert prov.is_available() is True
def test_unavailable_without_key(self, monkeypatch):
from plugins.memory.supermemory import SupermemoryMemoryProvider
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
prov = SupermemoryMemoryProvider()
assert prov.is_available() is False
# ---------------------------------------------------------------------------
# 4. Real sealed-venv durable-target gate accepts the new features.
#
# This is the exact hosted-Fly condition: HERMES_DISABLE_LAZY_INSTALLS=1 seals
# the venv, but HERMES_LAZY_INSTALL_TARGET redirects installs to a writable
# durable dir, so installs are still ALLOWED. We exercise the real
# _allow_lazy_installs() + ensure() flow end-to-end with only the pip
# subprocess stubbed.
# ---------------------------------------------------------------------------
class TestSealedVenvDurableTarget:
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_ensure_installs_into_durable_target_on_sealed_venv(
self, feature, monkeypatch, tmp_path
):
# Sealed venv + durable target = the published Docker image config.
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.setenv("HERMES_LAZY_INSTALL_TARGET", str(tmp_path / "lazy"))
# config.yaml kill-switch left at default (allow).
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {"allow_lazy_installs": True}},
)
# Real gate must permit installs because a durable target is set.
assert ld._allow_lazy_installs() is True, (
"sealed venv WITH a durable target must allow installs — this is "
"the path honcho/hindsight use on hosted Fly instances"
)
# Drive ensure(): missing first, satisfied after the (stubbed) install.
states = iter([False, True])
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states))
captured = {}
def fake_install(specs, **kw):
captured["specs"] = specs
captured["target_env"] = os.environ.get("HERMES_LAZY_INSTALL_TARGET")
return ld._InstallResult(True, "ok", "")
monkeypatch.setattr(ld, "_venv_pip_install", fake_install)
ld.ensure(feature, prompt=False) # must not raise
assert captured.get("specs") == ld.LAZY_DEPS[feature]
assert captured.get("target_env"), (
"install ran without the durable target env set"
)
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_sealed_venv_without_target_blocks(self, feature, monkeypatch):
# Sealed venv and NO durable target → installs blocked (can't mutate
# the sealed venv). Belt-and-suspenders: confirms the gate still
# protects the seal for these features.
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.delenv("HERMES_LAZY_INSTALL_TARGET", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {"allow_lazy_installs": True}},
)
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
ld.ensure(feature, prompt=False)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
from __future__ import annotations
from unittest.mock import MagicMock
import agent.file_safety as fs
from plugins.memory.retaindb import RetainDBMemoryProvider
def test_upload_file_rejects_hermes_credential_store(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes_home"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"OPENAI_API_KEY":"sk-test-secret"}', encoding="utf-8")
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
provider = RetainDBMemoryProvider()
provider._client = MagicMock()
result = provider._dispatch("retaindb_upload_file", {"local_path": str(auth_json)})
assert "error" in result
assert "credential store" in result["error"]
provider._client.upload_file.assert_not_called()
def test_upload_file_allows_regular_file(tmp_path):
note = tmp_path / "note.md"
note.write_text("# Note\n", encoding="utf-8")
provider = RetainDBMemoryProvider()
provider._client = MagicMock()
provider._client.upload_file.return_value = {
"file": {"id": "file-1", "name": "note.md"},
}
result = provider._dispatch("retaindb_upload_file", {"local_path": str(note)})
provider._client.upload_file.assert_called_once()
assert provider._client.upload_file.call_args.args[0] == note.read_bytes()
assert result["file"]["id"] == "file-1"
@@ -0,0 +1,625 @@
import json
import os
import stat
import threading
import pytest
from plugins.memory.supermemory import (
SupermemoryMemoryProvider,
_clean_text_for_capture,
_format_connection_summary,
_format_prefetch_context,
_load_supermemory_config,
_probe_supermemory_connection,
_save_supermemory_config,
)
class FakeClient:
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
self.api_key = api_key
self.timeout = timeout
self.container_tag = container_tag
self.search_mode = search_mode
self.add_calls = []
self.search_results = []
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
self.ingest_calls = []
self.forgotten_ids = []
self.forget_by_query_response = {"success": True, "message": "Forgot"}
def add_memory(self, content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
self.add_calls.append({
"content": content,
"metadata": metadata,
"entity_context": entity_context,
"container_tag": container_tag,
"custom_id": custom_id,
})
return {"id": "mem_123"}
def search_memories(self, query, *, limit=5, container_tag=None, search_mode=None):
return self.search_results
def get_profile(self, query=None, *, container_tag=None):
return self.profile_response
def forget_memory(self, memory_id, *, container_tag=None):
self.forgotten_ids.append(memory_id)
def forget_by_query(self, query, *, container_tag=None):
return self.forget_by_query_response
def ingest_conversation(self, session_id, messages, metadata=None):
self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata})
@pytest.fixture
def provider(monkeypatch, tmp_path):
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("session-1", hermes_home=str(tmp_path), platform="cli")
return p
def test_is_available_false_without_api_key(monkeypatch):
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
p = SupermemoryMemoryProvider()
assert p.is_available() is False
def test_is_available_true_when_import_missing_but_key_set(monkeypatch):
# Regression: is_available() must NOT gate on the supermemory SDK being
# importable. The SDK is lazy-installed at client construction (see
# _SupermemoryClient.__init__ -> tools.lazy_deps.ensure). Gating here is a
# chicken-and-egg trap: on a sealed Docker venv the package isn't present
# until ensure() runs, but ensure() only runs once the provider loads —
# which this gates. So with the key set and the SDK absent, the provider
# must still report available. Mirrors honcho/mem0 (config-presence only).
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "supermemory" or name.startswith("supermemory."):
raise ImportError("missing")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
p = SupermemoryMemoryProvider()
assert p.is_available() is True
def test_is_available_false_without_key(monkeypatch):
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
p = SupermemoryMemoryProvider()
assert p.is_available() is False
def test_load_and_save_config_round_trip(tmp_path):
_save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path))
cfg = _load_supermemory_config(str(tmp_path))
# container_tag is kept raw — sanitization happens in initialize() after template resolution
assert cfg["container_tag"] == "demo-tag"
assert cfg["auto_capture"] is False
assert cfg["auto_recall"] is True
def test_clean_text_for_capture_strips_injected_context():
text = "hello\n<supermemory-context>ignore me</supermemory-context>\nworld"
assert _clean_text_for_capture(text) == "hello\nworld"
def test_format_prefetch_context_deduplicates_overlap():
result = _format_prefetch_context(
static_facts=["Jordan prefers short answers"],
dynamic_facts=["Jordan prefers short answers", "Uses Hermes"],
search_results=[{"memory": "Uses Hermes", "similarity": 0.9}],
max_results=10,
)
assert result.count("Jordan prefers short answers") == 1
assert result.count("Uses Hermes") == 1
assert "<supermemory-context>" in result
def test_prefetch_includes_profile_on_first_turn(provider):
provider._client.profile_response = {
"static": ["Jordan prefers short answers"],
"dynamic": ["Current project is Supermemory provider"],
"search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
}
provider.on_turn_start(1, "start")
result = provider.prefetch("what am I working on?")
assert "User Profile (Persistent)" in result
assert "Recent Context" in result
assert "Relevant Memories" in result
def test_prefetch_skips_profile_between_frequency(provider):
provider._client.profile_response = {
"static": ["Jordan prefers short answers"],
"dynamic": ["Current project is Supermemory provider"],
"search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
}
provider.on_turn_start(2, "next")
result = provider.prefetch("what am I working on?")
assert "Relevant Memories" in result
assert "User Profile (Persistent)" not in result
def test_sync_turn_buffers_short_messages(provider):
# Trivial filtering is no longer applied at sync time — every non-empty turn
# is buffered and only the full session is written at session boundaries.
provider.sync_turn("ok", "sure", session_id="session-1")
assert provider._session_turns == [{"user": "ok", "assistant": "sure"}]
assert provider._client.add_calls == []
def test_sync_turn_buffers_cleaned_exchange(provider):
provider.sync_turn(
"Please remember this\n<supermemory-context>ignore</supermemory-context>",
"Got it, storing the context",
session_id="session-1",
)
assert len(provider._session_turns) == 1
turn = provider._session_turns[0]
assert "ignore" not in turn["user"]
assert turn["user"].startswith("Please remember this")
assert turn["assistant"] == "Got it, storing the context"
# Buffering only — no per-turn writes to the client
assert provider._client.add_calls == []
assert provider._client.ingest_calls == []
def test_on_session_end_ingests_clean_messages(provider):
messages = [
{"role": "system", "content": "skip"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
provider.on_session_end(messages)
assert len(provider._client.ingest_calls) == 1
payload = provider._client.ingest_calls[0]
assert payload["session_id"] == "session-1"
assert payload["messages"] == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
assert payload["metadata"]["type"] == "full_session"
assert payload["metadata"]["session_id"] == "session-1"
assert payload["metadata"]["message_count"] == 2
# Buffer is cleared after a normal session-end ingest.
assert provider._session_turns == []
def test_merge_metadata_stamps_sm_source():
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
# app (functional routing, not telemetry) — must always be present.
from plugins.memory.supermemory import _SupermemoryClient
client = _SupermemoryClient.__new__(_SupermemoryClient)
merged = client._merge_metadata({"type": "explicit_memory"})
assert merged["sm_source"] == "hermes"
assert merged["type"] == "explicit_memory"
# Legacy "source" is migrated into "type" when type is absent.
merged2 = client._merge_metadata({"source": "conversation_turn"})
assert merged2["sm_source"] == "hermes"
assert merged2["type"] == "conversation_turn"
assert "source" not in merged2
def test_on_memory_write_tracks_thread(provider):
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
assert provider._write_thread is not None
provider._write_thread.join(timeout=1)
assert len(provider._client.add_calls) == 1
assert provider._client.add_calls[0]["metadata"]["type"] == "explicit_memory"
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch):
started = threading.Event()
release = threading.Event()
def slow_add_memory(content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
started.set()
release.wait(timeout=1)
provider._client.add_calls.append({
"content": content,
"metadata": metadata,
"entity_context": entity_context,
})
return {"id": "mem_slow"}
monkeypatch.setattr(provider._client, "add_memory", slow_add_memory)
# sync_turn now only buffers — no thread is spawned.
provider.sync_turn(
"Please remember this request in long-term memory",
"Absolutely, I will keep that in long-term memory.",
session_id="session-1",
)
assert provider._sync_thread is None
assert len(provider._session_turns) == 1
# on_memory_write still runs on a background thread.
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
assert started.wait(timeout=1)
assert provider._write_thread is not None
release.set()
provider.shutdown()
# All tracked threads joined and cleared.
assert provider._sync_thread is None
assert provider._write_thread is None
assert provider._prefetch_thread is None
# Explicit memory write went through.
assert len(provider._client.add_calls) == 1
# Buffered turn was flushed as a partial full-session ingest.
assert len(provider._client.ingest_calls) == 1
payload = provider._client.ingest_calls[0]
assert payload["session_id"] == "session-1"
assert payload["metadata"]["partial"] is True
assert payload["metadata"]["type"] == "full_session"
def test_store_tool_returns_saved_payload(provider):
result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"}))
assert result["saved"] is True
assert result["id"] == "mem_123"
def test_search_tool_formats_results(provider):
provider._client.search_results = [
{"id": "m1", "memory": "Jordan likes concise docs", "similarity": 0.92}
]
result = json.loads(provider.handle_tool_call("supermemory_search", {"query": "concise docs"}))
assert result["count"] == 1
assert result["results"][0]["similarity"] == 92
def test_forget_tool_by_id(provider):
result = json.loads(provider.handle_tool_call("supermemory_forget", {"id": "m1"}))
assert result == {"forgotten": True, "id": "m1"}
assert provider._client.forgotten_ids == ["m1"]
def test_forget_tool_by_query(provider):
provider._client.forget_by_query_response = {"success": True, "message": "Forgot one", "id": "m7"}
result = json.loads(provider.handle_tool_call("supermemory_forget", {"query": "that thing"}))
assert result["success"] is True
assert result["id"] == "m7"
def test_profile_tool_formats_sections(provider):
provider._client.profile_response = {
"static": ["Jordan prefers concise docs"],
"dynamic": ["Working on Supermemory provider"],
"search_results": [],
}
result = json.loads(provider.handle_tool_call("supermemory_profile", {}))
assert result["static_count"] == 1
assert result["dynamic_count"] == 1
assert "User Profile (Persistent)" in result["profile"]
def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch):
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
p = SupermemoryMemoryProvider()
result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"}))
assert "error" in result
# -- Identity template tests --------------------------------------------------
def test_identity_template_resolved_in_container_tag(monkeypatch, tmp_path):
"""container_tag with {identity} resolves to profile-scoped tag."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli", agent_identity="coder")
assert p._container_tag == "hermes_coder"
def test_identity_template_default_profile(monkeypatch, tmp_path):
"""Without agent_identity kwarg, {identity} resolves to 'default'."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._container_tag == "hermes_default"
def test_container_tag_env_var_override(monkeypatch, tmp_path):
"""SUPERMEMORY_CONTAINER_TAG env var overrides config."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setenv("SUPERMEMORY_CONTAINER_TAG", "env-override")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._container_tag == "env_override"
# -- Search mode tests --------------------------------------------------------
def test_search_mode_config_passed_to_client(monkeypatch, tmp_path):
"""search_mode from config is passed to _SupermemoryClient."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"search_mode": "memories"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._search_mode == "memories"
assert p._client.search_mode == "memories"
def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
"""Invalid search_mode falls back to 'hybrid'."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"search_mode": "invalid_mode"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._search_mode == "hybrid"
# -- Multi-container tests ----------------------------------------------------
def test_multi_container_disabled_by_default(provider):
"""Multi-container is off by default; schemas have no container_tag param."""
assert provider._enable_custom_containers is False
schemas = provider.get_tool_schemas()
for s in schemas:
assert "container_tag" not in s["parameters"]["properties"]
def test_multi_container_enabled_adds_schema_param(monkeypatch, tmp_path):
"""When enabled, tool schemas include container_tag parameter."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["project-alpha", "shared"],
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._enable_custom_containers is True
assert p._allowed_containers == ["hermes", "project_alpha", "shared"]
schemas = p.get_tool_schemas()
for s in schemas:
assert "container_tag" in s["parameters"]["properties"]
def test_multi_container_tool_store_with_custom_tag(monkeypatch, tmp_path):
"""supermemory_store uses the resolved container_tag when multi-container is enabled."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["project-alpha"],
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
result = json.loads(p.handle_tool_call("supermemory_store", {
"content": "test memory",
"container_tag": "project-alpha",
}))
assert result["saved"] is True
assert result["container_tag"] == "project_alpha"
assert p._client.add_calls[-1]["container_tag"] == "project_alpha"
def test_multi_container_rejects_unlisted_tag(monkeypatch, tmp_path):
"""Tool calls with a non-whitelisted container_tag return an error."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["allowed-tag"],
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
result = json.loads(p.handle_tool_call("supermemory_store", {
"content": "test",
"container_tag": "forbidden-tag",
}))
assert "error" in result
assert "not allowed" in result["error"]
def test_multi_container_system_prompt_includes_instructions(monkeypatch, tmp_path):
"""system_prompt_block includes container list and instructions when multi-container is enabled."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({
"enable_custom_container_tags": True,
"custom_containers": ["docs"],
"custom_container_instructions": "Use docs for documentation context.",
}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
block = p.system_prompt_block()
assert "Multi-container mode enabled" in block
assert "docs" in block
assert "Use docs for documentation context." in block
def test_get_config_schema_minimal():
"""get_config_schema only returns the API key field."""
p = SupermemoryMemoryProvider()
schema = p.get_config_schema()
assert len(schema) == 1
assert schema[0]["key"] == "api_key"
assert schema[0]["secret"] is True
def test_format_connection_summary_ok():
summary = _format_connection_summary({
"ok": True,
"container_tag": "hermes_coder",
"profile_facts": 12,
"auto_recall": True,
"auto_capture": False,
})
assert "✓ Connected" in summary
assert "container: hermes_coder" in summary
assert "12 profile facts" in summary
assert "auto_recall on" in summary
assert "auto_capture off" in summary
def test_format_connection_summary_single_fact_and_error():
one = _format_connection_summary({
"ok": True,
"container_tag": "hermes",
"profile_facts": 1,
"auto_recall": True,
"auto_capture": True,
})
assert "1 profile fact" in one
assert "1 profile facts" not in one
err = _format_connection_summary({
"ok": False,
"error": "invalid API key",
"container_tag": "hermes",
"auto_recall": True,
"auto_capture": True,
})
assert "✗ invalid API key" in err
assert "container: hermes" in err
def test_probe_supermemory_connection_missing_key(tmp_path):
status = _probe_supermemory_connection("", str(tmp_path))
assert status["ok"] is False
assert status["error"] == "SUPERMEMORY_API_KEY not set"
assert status["container_tag"] == "hermes"
def _stub_supermemory_importable(monkeypatch):
"""Make ``__import__("supermemory")`` succeed without the real package.
``_probe_supermemory_connection`` guards on ``__import__("supermemory")``
before using the (mocked) client, so tests that mock ``_SupermemoryClient``
must also satisfy that import guard — otherwise they only pass in an
environment where the optional ``supermemory`` package happens to be
installed (and fail on a clean checkout / CI). Mirrors the inverse stub in
``test_is_available_false_when_import_missing``.
"""
import builtins
import types
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "supermemory":
return types.ModuleType("supermemory")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
def test_probe_supermemory_connection_success(monkeypatch, tmp_path):
_stub_supermemory_importable(monkeypatch)
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
class CountingClient(FakeClient):
def get_profile(self, query=None, *, container_tag=None):
return {
"static": ["Prefers TypeScript"],
"dynamic": ["", "Working on Hermes"],
"search_results": [],
}
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", CountingClient)
status = _probe_supermemory_connection("test-key", str(tmp_path))
assert status["ok"] is True
assert status["profile_facts"] == 2
assert status["auto_recall"] is True
def test_probe_supermemory_connection_client_error(monkeypatch, tmp_path):
_stub_supermemory_importable(monkeypatch)
class BrokenClient(FakeClient):
def get_profile(self, query=None, *, container_tag=None):
raise RuntimeError("API unavailable")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", BrokenClient)
status = _probe_supermemory_connection("test-key", str(tmp_path))
assert status["ok"] is False
assert "API unavailable" in status["error"]
def test_get_status_config_returns_summary(monkeypatch, tmp_path):
_stub_supermemory_importable(monkeypatch)
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
monkeypatch.setattr(
"hermes_constants.get_hermes_home",
lambda: tmp_path,
)
result = SupermemoryMemoryProvider().get_status_config({})
assert "summary" in result
assert "✓ Connected" in result["summary"]
assert "container: hermes" in result["summary"]
def test_post_setup_writes_config_and_prints_summary(monkeypatch, tmp_path, capsys):
config: dict = {"memory": {}}
monkeypatch.setenv("SUPERMEMORY_API_KEY", "")
monkeypatch.setattr(
"hermes_cli.memory_setup._prompt",
lambda label, secret=True, default=None: "new-api-key",
)
monkeypatch.setattr(
"plugins.memory.supermemory._probe_supermemory_connection",
lambda api_key, hermes_home, **kwargs: {
"ok": True,
"container_tag": "hermes",
"profile_facts": 3,
"auto_recall": True,
"auto_capture": True,
},
)
saved: dict = {}
def fake_save_config(cfg):
saved.update(cfg)
monkeypatch.setattr("hermes_cli.config.save_config", fake_save_config)
SupermemoryMemoryProvider().post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "supermemory"
assert saved["memory"]["provider"] == "supermemory"
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
assert "SUPERMEMORY_API_KEY=new-api-key" in env_text
out = capsys.readouterr().out
assert "✓ Connected" in out
assert "3 profile facts" in out
assert "Memory provider: supermemory" in out
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path):
"""supermemory.json must be written with 0o600 so API key is not world-readable."""
_save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path))
config_file = tmp_path / "supermemory.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
@@ -0,0 +1,118 @@
"""Unit tests for the custom provider profile's reasoning wiring.
``provider=custom`` covers any OpenAI-compatible endpoint the user points
Hermes at — local Ollama, vLLM, llama.cpp, and hosted reasoning APIs like
GLM-5.2 on Volcengine ARK. Before #57601's salvage, ``CustomProfile`` emitted
nothing when reasoning was *enabled*, so a configured ``reasoning_effort``
was silently dropped for every custom endpoint.
These tests pin the wire-shape contract:
- disabled → extra_body.think = False
- enabled + effort → top-level reasoning_effort (native OpenAI-compat
format GLM/ARK expect), passed through verbatim
including ``max``/``xhigh``
- enabled + no effort → nothing emitted (endpoint's server default applies)
- ollama_num_ctx → extra_body.options.num_ctx, orthogonal to reasoning
"""
from __future__ import annotations
import pytest
@pytest.fixture
def custom_profile():
"""Resolve the registered custom profile via the global registry.
Importing ``model_tools`` triggers plugin discovery, which registers the
``custom`` profile. Going through ``get_provider_profile`` keeps the test
honest — if the registered class is ever downgraded to a plain
``ProviderProfile``, the assertions below collapse.
"""
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("custom")
assert profile is not None, "custom provider profile must be registered"
return profile
class TestCustomReasoningWireShape:
"""``build_api_kwargs_extras`` produces the correct wire format."""
def test_no_reasoning_config_emits_nothing(self, custom_profile):
"""Unset reasoning → omit everything so the endpoint's default applies."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config=None, model="glm-5.2"
)
assert eb == {}
assert tl == {}
def test_disabled_sends_think_false(self, custom_profile):
"""enabled=False → extra_body.think = False (Ollama thinking-off flag)."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="glm-5.2"
)
assert eb == {"think": False}
assert tl == {}
def test_effort_none_sends_think_false(self, custom_profile):
"""effort='none' is the disable alias → think=False, no effort."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "none"}, model="glm-5.2"
)
assert eb == {"think": False}
assert tl == {}
@pytest.mark.parametrize(
"effort", ["minimal", "low", "medium", "high", "xhigh", "max"]
)
def test_enabled_effort_goes_top_level(self, custom_profile, effort):
"""enabled + effort → TOP-LEVEL reasoning_effort, passed through verbatim.
GLM-5.2/ARK and OpenAI-compatible reasoning APIs read reasoning_effort
as a top-level string, not nested in extra_body. ``max`` is GLM's
native deep-reasoning level and must survive.
"""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}, model="glm-5.2"
)
assert tl == {"reasoning_effort": effort}
assert "reasoning_effort" not in eb
assert "think" not in eb
def test_enabled_without_effort_emits_nothing(self, custom_profile):
"""enabled but no effort → omit; do NOT force a level the user didn't pick."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True}, model="glm-5.2"
)
assert eb == {}
assert tl == {}
def test_does_not_force_think_true_on_enable(self, custom_profile):
"""We must never send think=True on enable — it's Ollama-only and
would 400 on GLM/vLLM endpoints that don't recognize it."""
eb, _ = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"}, model="glm-5.2"
)
assert eb.get("think") is not True
class TestCustomReasoningWithNumCtx:
"""Ollama num_ctx and reasoning are independent and compose."""
def test_num_ctx_alone(self, custom_profile):
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config=None, ollama_num_ctx=8192, model="qwen3"
)
assert eb == {"options": {"num_ctx": 8192}}
assert tl == {}
def test_num_ctx_with_effort(self, custom_profile):
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
ollama_num_ctx=8192,
model="qwen3",
)
assert eb == {"options": {"num_ctx": 8192}}
assert tl == {"reasoning_effort": "high"}
@@ -0,0 +1,207 @@
"""Unit tests for the DeepSeek provider profile's thinking-mode wiring.
DeepSeek V4 (and the legacy ``deepseek-reasoner``) expects every request to
carry an explicit ``extra_body.thinking`` parameter. Omitting it makes the
server default to thinking-mode ON, which then enforces the
``reasoning_content``-must-be-echoed-back contract on subsequent turns and
breaks the conversation with HTTP 400 (#15700, #17212, #17825).
These tests pin the profile's wire-shape contract so DeepSeek requests stay
correctly shaped without going live.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def deepseek_profile():
"""Resolve the registered DeepSeek profile.
Going through ``providers.get_provider_profile`` keeps the test honest —
if someone later replaces the registered class with a plain
``ProviderProfile``, every assertion below collapses.
"""
# ``model_tools`` triggers plugin discovery on import, which is what
# registers the DeepSeek profile in the global provider registry.
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("deepseek")
assert profile is not None, "deepseek provider profile must be registered"
return profile
class TestDeepSeekThinkingWireShape:
"""``build_api_kwargs_extras`` produces DeepSeek's exact wire format."""
def test_v4_pro_default_enables_thinking_without_effort(self, deepseek_profile):
"""No reasoning_config → thinking enabled, server picks default effort."""
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config=None, model="deepseek-v4-pro"
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
def test_v4_pro_enabled_with_high_effort(self, deepseek_profile):
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="deepseek-v4-pro",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {"reasoning_effort": "high"}
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_standard_efforts_pass_through(self, deepseek_profile, effort):
_, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="deepseek-v4-pro",
)
assert top_level == {"reasoning_effort": effort}
@pytest.mark.parametrize("effort", ["xhigh", "max", "MAX", " Max "])
def test_xhigh_and_max_normalize_to_max(self, deepseek_profile, effort):
_, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="deepseek-v4-pro",
)
assert top_level == {"reasoning_effort": "max"}
def test_explicitly_disabled_sends_disabled_marker(self, deepseek_profile):
"""``reasoning_config.enabled=False`` → ``thinking.type=disabled``.
The crucial bit is that the parameter is *sent* at all — DeepSeek
defaults to thinking-on when ``thinking`` is absent.
"""
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="deepseek-v4-pro"
)
assert extra_body == {"thinking": {"type": "disabled"}}
# No effort when disabled — DeepSeek rejects it.
assert top_level == {}
def test_disabled_ignores_effort_field(self, deepseek_profile):
"""Effort silently dropped when thinking is off."""
_, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"},
model="deepseek-v4-pro",
)
assert top_level == {}
def test_unknown_effort_omits_top_level(self, deepseek_profile):
"""Garbage effort → omit reasoning_effort so DeepSeek applies its default."""
_, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "garbage"},
model="deepseek-v4-pro",
)
assert top_level == {}
def test_empty_effort_omits_top_level(self, deepseek_profile):
_, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": ""},
model="deepseek-v4-pro",
)
assert top_level == {}
class TestDeepSeekModelGating:
"""V4 family + ``deepseek-reasoner`` get thinking; V3 stays untouched."""
@pytest.mark.parametrize(
"model",
[
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-future-variant",
"deepseek-reasoner",
"DEEPSEEK-V4-PRO", # case-insensitive
],
)
def test_thinking_capable_models_emit_thinking(self, deepseek_profile, model):
extra_body, _ = deepseek_profile.build_api_kwargs_extras(
reasoning_config=None, model=model
)
assert extra_body == {"thinking": {"type": "enabled"}}
@pytest.mark.parametrize(
"model",
[
"deepseek-chat", # V3 alias
"deepseek-v3-0324", # explicit V3
"deepseek-v3.1", # V3 minor revisions
"", # bare/unknown
None, # missing
"deepseek-unknown", # unrecognized
],
)
def test_non_thinking_models_emit_nothing(self, deepseek_profile, model):
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"}, model=model
)
assert extra_body == {}
assert top_level == {}
class TestDeepSeekFullKwargsIntegration:
"""End-to-end: the transport's full kwargs match DeepSeek's live wire format.
The live test harness in ``tests/run_agent/test_deepseek_v4_thinking_live.py``
sends ``{"reasoning_effort": "high", "extra_body": {"thinking": {"type":
"enabled"}}}``. Confirm the transport produces that exact shape when wired
through the registered DeepSeek profile.
"""
def test_full_kwargs_match_live_wire_shape(self, deepseek_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="deepseek-v4-pro",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=deepseek_profile,
reasoning_config={"enabled": True, "effort": "high"},
base_url="https://api.deepseek.com/v1",
provider_name="deepseek",
)
assert kwargs["model"] == "deepseek-v4-pro"
assert kwargs["reasoning_effort"] == "high"
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
def test_v3_chat_full_kwargs_omit_thinking(self, deepseek_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="deepseek-chat",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=deepseek_profile,
reasoning_config={"enabled": True, "effort": "high"},
base_url="https://api.deepseek.com/v1",
provider_name="deepseek",
)
assert "reasoning_effort" not in kwargs
assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {})
class TestDeepSeekAuxModel:
"""DeepSeek aux model is set on the profile so users stop seeing the
bogus 'No auxiliary LLM provider configured' warning (#26924).
Pinned at the profile layer rather than the legacy
`_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict — new providers are
expected to set `default_aux_model` on `ProviderProfile`, and the
fallback dict only exists for providers that predate the profiles
system.
"""
def test_profile_advertises_deepseek_chat(self, deepseek_profile):
assert deepseek_profile.default_aux_model == "deepseek-chat"
def test_consumer_api_returns_deepseek_chat(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") == "deepseek-chat"
def test_consumer_api_returns_non_empty(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") != ""
@@ -0,0 +1,81 @@
"""Unit tests for the Fireworks AI provider profile.
Pins the profile's contract without going live: identity, alias registration,
and the pay-as-you-go model defaults (direct catalog ``/models/``
IDs, not the router-only tier).
"""
from __future__ import annotations
import pytest
@pytest.fixture
def fireworks_profile():
"""Resolve the registered Fireworks profile through the real discovery path."""
# Importing model_tools triggers plugin discovery, registering the profile.
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("fireworks")
assert profile is not None, "fireworks provider profile must be registered"
return profile
class TestFireworksIdentity:
def test_core_fields(self, fireworks_profile):
p = fireworks_profile
assert p.name == "fireworks"
assert p.auth_type == "api_key"
assert p.base_url == "https://api.fireworks.ai/inference/v1"
assert "FIREWORKS_API_KEY" in p.env_vars
assert "FIREWORKS_BASE_URL" not in p.env_vars
def test_display_metadata_present(self, fireworks_profile):
# Prominence copy is surfaced in the picker; keep it non-empty rather
# than pinning exact marketing wording (that's expected to change).
assert fireworks_profile.display_name
assert fireworks_profile.description
assert fireworks_profile.signup_url.startswith("https://")
class TestFireworksHeaders:
def test_no_partner_attribution_headers(self, fireworks_profile):
assert "HTTP-Referer" not in fireworks_profile.default_headers
assert "X-Title" not in fireworks_profile.default_headers
class TestFireworksAliases:
@pytest.mark.parametrize("alias", ["fireworks-ai", "fw"])
def test_alias_resolves_via_registry(self, fireworks_profile, alias):
import providers
resolved = providers.get_provider_profile(alias)
assert resolved is not None
assert resolved.name == "fireworks"
def test_aliases_declared_on_profile(self, fireworks_profile):
assert "fireworks-ai" in fireworks_profile.aliases
assert "fw" in fireworks_profile.aliases
class TestFireworksModelDefaults:
"""Defaults must be usable with a standard pay-as-you-go key.
PAYG keys address ``accounts/fireworks/models/...`` directly; the bundled
defaults target that (the BYOK motion) so a fresh key works out of the box,
and use the standard tier rather than turbo as the out-of-box default.
"""
def test_aux_model_is_payg_model_not_router(self, fireworks_profile):
aux = fireworks_profile.default_aux_model
assert aux.startswith("accounts/fireworks/models/"), aux
assert "/routers/" not in aux
assert "turbo" not in aux.lower()
def test_fallback_models_are_payg_models_not_routers(self, fireworks_profile):
assert fireworks_profile.fallback_models, "expected curated fallbacks"
for model in fireworks_profile.fallback_models:
assert model.startswith("accounts/fireworks/models/"), model
assert "/routers/" not in model
assert "turbo" not in model.lower(), model
@@ -0,0 +1,130 @@
"""Unit tests for the Kimi/Moonshot provider profile's reasoning wiring.
Moonshot's OpenAI-compat endpoint (``api.moonshot.ai/v1``) treats
``extra_body.thinking`` and a top-level ``reasoning_effort`` as mutually
exclusive. The profile must send at most one of them — never both — so a
request can't trip "cannot specify both 'thinking' and 'reasoning_effort'".
This mirrors the kimi-k2 handling already shipped for the opencode-go relay
(see ``tests/plugins/model_providers/test_opencode_go_profile.py``).
"""
from __future__ import annotations
import pytest
@pytest.fixture
def kimi_profile():
"""Resolve the registered Kimi profile via the provider registry.
Importing ``model_tools`` triggers plugin discovery, which registers the
Kimi profile. Going through ``get_provider_profile`` keeps the test honest:
if the registered class is ever swapped for a plain ``ProviderProfile`` the
assertions below collapse.
"""
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("kimi-coding")
assert profile is not None, "kimi-coding provider profile must be registered"
return profile
class TestKimiReasoningWireShape:
"""``build_api_kwargs_extras`` never emits thinking + reasoning_effort together."""
def test_no_config_enables_thinking_without_effort(self, kimi_profile):
"""No reasoning_config → thinking on, server picks the depth.
Regression guard: this path previously also sent
``reasoning_effort="medium"``, pairing thinking + effort on every
default call.
"""
extra_body, top_level = kimi_profile.build_api_kwargs_extras(reasoning_config=None)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_explicit_effort_sends_effort_only(self, kimi_profile, effort):
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}
)
assert top_level == {"reasoning_effort": effort}
assert "thinking" not in extra_body
def test_enabled_without_effort_falls_back_to_thinking(self, kimi_profile):
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True}
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
@pytest.mark.parametrize("effort", ["", "garbage", "xhigh", "max"])
def test_unrecognized_effort_falls_back_to_thinking(self, kimi_profile, effort):
"""Unknown/strong efforts aren't in Moonshot's low|medium|high set, so
we drop to the thinking toggle rather than sending an invalid effort."""
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
def test_disabled_sends_thinking_disabled_only(self, kimi_profile):
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
def test_disabled_ignores_effort(self, kimi_profile):
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"}
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
@pytest.mark.parametrize(
"reasoning_config",
[
None,
{"enabled": True},
{"enabled": True, "effort": "high"},
{"enabled": True, "effort": "garbage"},
{"enabled": False},
{"enabled": False, "effort": "low"},
],
)
def test_never_emits_both(self, kimi_profile, reasoning_config):
"""The core invariant: thinking and reasoning_effort are never both set."""
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config=reasoning_config
)
assert not ("thinking" in extra_body and "reasoning_effort" in top_level)
class TestKimiFullKwargsIntegration:
"""The transport's full kwargs carry at most one reasoning knob."""
def _build(self, kimi_profile, reasoning_config):
from agent.transports.chat_completions import ChatCompletionsTransport
return ChatCompletionsTransport().build_kwargs(
model="kimi-k2-turbo-preview",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=kimi_profile,
reasoning_config=reasoning_config,
base_url="https://api.moonshot.ai/v1",
provider_name="kimi-coding",
)
def test_explicit_effort_omits_thinking(self, kimi_profile):
kwargs = self._build(kimi_profile, {"enabled": True, "effort": "high"})
assert kwargs["reasoning_effort"] == "high"
assert "thinking" not in kwargs.get("extra_body", {})
def test_no_config_omits_effort(self, kimi_profile):
kwargs = self._build(kimi_profile, None)
assert "reasoning_effort" not in kwargs
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
@@ -0,0 +1,232 @@
"""Unit tests for the MiniMax provider profile.
Three MiniMax provider profiles (`minimax` direct API, `minimax-cn` China direct
API, `minimax-oauth` browser OAuth) all advertise a `default_aux_model` on
their `ProviderProfile`. The previous M2.7 / M2.7-highspeed values were
stale relative to the current frontier model (M3, released 2026-06-01) and
inconsistent with the `_PROVIDER_MODELS["minimax"]` catalog top entry in
`hermes_cli/models.py`.
This file pins the new defaults so the choice is reviewable and any future
revert shows up in a failing test rather than silent behavior drift.
Refs:
- Issue #36196: M3 support request
- PR #36205 (closed unmerged): Csrayz's M3 + 1M context work
- PR #36212 (open): adds M3 to `_PROVIDER_MODELS["minimax"]` catalog
- PR #6082: M2.7-highspeed → M2.7 for aux model (half-price fix)
- Commit 773a0faca: same profile-layer fix pattern for `deepseek`
"""
from __future__ import annotations
import pytest
@pytest.fixture(params=["minimax", "minimax-cn", "minimax-oauth"])
def minimax_profile(request):
"""Resolve each registered MiniMax profile.
Going through ``providers.get_provider_profile`` keeps the test honest —
if someone later replaces the registered class with a plain
``ProviderProfile``, every assertion below collapses.
"""
import model_tools # noqa: F401 -- triggers plugin discovery
import providers
profile = providers.get_provider_profile(request.param)
assert profile is not None, f"{request.param} provider profile must be registered"
return profile, request.param
class TestMinimaxAuxModelM3:
"""MiniMax profile aux model is the new frontier M3, not the stale M2.7.
The catalog top entry is ``MiniMax-M3`` in
``hermes_cli.models._PROVIDER_MODELS['minimax']`` and the
user-facing ``model.default`` for a Token-Plan install is M3,
so pinning the aux default to the same model keeps the runtime
consistent (same auth, same billing pool, same rate limits, no
surprise 2x-cost highspeed variant). M3 was released 2026-06-01
— picking it as the aux default matches the forward-looking
catalog order rather than the pre-M3 era.
"""
@pytest.mark.parametrize(
"provider_id,expected",
[
("minimax", "MiniMax-M3"),
("minimax-cn", "MiniMax-M3"),
# minimax-oauth sticks with M2.7: the OAuth / Coding Plan
# tier historically used -highspeed (PR #6082 collapsed that
# to plain M2.7 to avoid the 2x TPS surcharge). M3 is not on
# the OAuth/Coding Plan tier per platform docs as of this PR,
# so the safe choice is the cheapest generally-available
# M2.7 — matching PR #6082's intent.
("minimax-oauth", "MiniMax-M2.7"),
],
)
def test_profile_advertises_expected_aux_model(
self, provider_id, expected
):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile(provider_id)
assert profile is not None
assert profile.default_aux_model == expected, (
f"{provider_id} default_aux_model drifted to "
f"{profile.default_aux_model!r}, expected {expected!r}"
)
def test_consumer_api_returns_non_empty_for_each_provider(self, minimax_profile):
from agent.auxiliary_client import _get_aux_model_for_provider
profile, provider_id = minimax_profile
resolved = _get_aux_model_for_provider(provider_id)
assert resolved != "", (
f"_get_aux_model_for_provider({provider_id!r}) returned empty — "
"the 'No auxiliary LLM provider configured' warning will fire on "
f"every {provider_id} session even though the profile advertises "
f"default_aux_model={profile.default_aux_model!r}"
)
assert resolved == profile.default_aux_model, (
f"_get_aux_model_for_provider({provider_id!r}) returned "
f"{resolved!r} but profile advertises {profile.default_aux_model!r} "
"— the consumer API and the profile have drifted out of sync"
)
class TestMinimaxAuxModelNotHighspeed:
"""Regression guard against re-introducing the M2.7-highspeed aux default.
PR #6082 collapsed the highspeed aux choice to plain M2.7 because the
highspeed variant costs 2x with no real benefit for compression / vision /
session-search aux tasks. None of the three MiniMax profiles should
silently re-introduce that 2x-cost path.
"""
@pytest.mark.parametrize("provider_id", ["minimax", "minimax-cn", "minimax-oauth"])
def test_default_aux_model_is_not_highspeed(self, provider_id):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile(provider_id)
assert profile is not None
assert "highspeed" not in profile.default_aux_model.lower(), (
f"{provider_id} default_aux_model={profile.default_aux_model!r} "
"is a -highspeed variant — that costs 2x for the same model and "
"broke #4082 the first time. Revert to plain M2.7 or M3."
)
class TestMinimaxM3OpenAIReasoningWireShape:
"""MiniMax-M3 on api.minimax.io/v1 gets MiniMax's OpenAI-compatible knobs."""
def test_m3_openai_route_requests_reasoning_split_by_default(self):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("minimax")
assert profile is not None
extra_body, top_level = profile.build_api_kwargs_extras(
reasoning_config=None,
model="MiniMax-M3",
base_url="https://api.minimax.io/v1",
)
assert extra_body == {"reasoning_split": True}
assert top_level == {}
def test_m3_openai_route_maps_explicit_effort_to_adaptive_only(self):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("minimax")
assert profile is not None
extra_body, top_level = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="MiniMax-M3",
base_url="https://api.minimax.io/v1",
)
assert extra_body == {
"reasoning_split": True,
"thinking": {"type": "adaptive"},
}
assert top_level == {}
def test_m3_openai_route_does_not_send_reasoning_effort(self):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("minimax")
assert profile is not None
extra_body, _top_level = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "xhigh"},
model="MiniMax-M3",
base_url="https://api.minimax.io/v1/",
)
assert extra_body == {
"reasoning_split": True,
"thinking": {"type": "adaptive"},
}
def test_m3_openai_route_can_disable_thinking(self):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("minimax")
assert profile is not None
extra_body, top_level = profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"},
model="MiniMax-M3",
base_url="https://api.minimax.io/v1",
)
assert extra_body == {
"reasoning_split": True,
"thinking": {"type": "disabled"},
}
assert top_level == {}
@pytest.mark.parametrize(
"model,base_url",
[
("MiniMax-M2.7", "https://api.minimax.io/v1"),
("MiniMax-M3", "https://api.minimax.io/anthropic"),
("MiniMax-M3", "https://api.minimaxi.com/v1"),
],
)
def test_non_m3_or_non_global_openai_routes_emit_no_openai_reasoning_knobs(
self, model, base_url
):
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("minimax")
assert profile is not None
extra_body, top_level = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model=model,
base_url=base_url,
)
assert extra_body == {}
assert top_level == {}
def test_transport_threads_base_url_to_profile(self):
import model_tools # noqa: F401
import providers
from agent.transports.chat_completions import ChatCompletionsTransport
profile = providers.get_provider_profile("minimax")
assert profile is not None
kwargs = ChatCompletionsTransport().build_kwargs(
model="MiniMax-M3",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=profile,
reasoning_config={"enabled": True, "effort": "medium"},
base_url="https://api.minimax.io/v1",
)
assert kwargs["extra_body"] == {
"reasoning_split": True,
"thinking": {"type": "adaptive"},
}
@@ -0,0 +1,153 @@
"""Unit tests for the Ollama Cloud provider profile's reasoning-effort wiring.
Ollama Cloud's ``/v1/chat/completions`` endpoint supports top-level
``reasoning_effort`` with values ``none``, ``low``, ``medium``, ``high``,
and (undocumented but empirically confirmed) ``max``. The profile maps
Hermes's ``xhigh`` → ``max`` to unlock DeepSeek V4's "Max thinking" tier
and passes the standard levels through unchanged.
These tests pin the profile's wire-shape contract so Ollama Cloud
requests carry the correct ``reasoning_effort`` field.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def ollama_cloud_profile():
"""Resolve the registered Ollama Cloud profile.
Going through ``providers.get_provider_profile`` keeps the test
honest — if someone replaces the registered class with a plain
``ProviderProfile``, every assertion below collapses.
"""
# ``model_tools`` triggers plugin discovery on import, which is what
# registers the Ollama Cloud profile in the global provider registry.
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("ollama-cloud")
assert profile is not None, "ollama-cloud provider profile must be registered"
return profile
class TestOllamaCloudReasoningEffort:
"""``build_api_kwargs_extras`` emits correct top-level ``reasoning_effort``."""
# ── xhigh / max → max ──────────────────────────────────────────
@pytest.mark.parametrize("effort", ["xhigh", "max", "MAX", " Max "])
def test_xhigh_and_max_normalize_to_max(self, ollama_cloud_profile, effort):
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "max"}
# ── low / medium / high pass through ───────────────────────────
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_standard_efforts_pass_through(self, ollama_cloud_profile, effort):
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
)
assert top_level == {"reasoning_effort": effort}
# ── disabled → no reasoning_effort emitted ─────────────────────
def test_explicitly_disabled_emits_nothing(self, ollama_cloud_profile):
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
)
assert extra_body == {}
assert top_level == {}
def test_disabled_ignores_effort_field(self, ollama_cloud_profile):
"""Effort silently dropped when thinking is off."""
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"},
)
assert top_level == {}
# ── none effort → no reasoning_effort ──────────────────────────
def test_none_effort_emits_nothing(self, ollama_cloud_profile):
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "none"},
)
assert extra_body == {}
assert top_level == {}
# ── missing / empty effort → let model default ─────────────────
def test_no_reasoning_config_emits_nothing(self, ollama_cloud_profile):
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config=None,
)
assert extra_body == {}
assert top_level == {}
def test_empty_effort_emits_nothing(self, ollama_cloud_profile):
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": ""},
)
assert top_level == {}
def test_no_effort_key_emits_nothing(self, ollama_cloud_profile):
"""When effort key is absent, let the model use its default."""
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True},
)
assert top_level == {}
# ── unknown effort → forwarded as-is ───────────────────────────
def test_unknown_effort_forwarded(self, ollama_cloud_profile):
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "future-tier"},
)
assert top_level == {"reasoning_effort": "future-tier"}
class TestOllamaCloudFullKwargsIntegration:
"""End-to-end: the transport's full kwargs include reasoning_effort."""
def test_full_kwargs_with_xhigh(self, ollama_cloud_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="deepseek-v4-pro:cloud",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=ollama_cloud_profile,
reasoning_config={"enabled": True, "effort": "xhigh"},
base_url="https://ollama.com/v1",
provider_name="ollama-cloud",
)
assert kwargs["model"] == "deepseek-v4-pro:cloud"
assert kwargs["reasoning_effort"] == "max"
# No extra_body — Ollama Cloud uses top-level reasoning_effort
assert "extra_body" not in kwargs or "reasoning" not in kwargs.get("extra_body", {})
def test_full_kwargs_with_disabled(self, ollama_cloud_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="deepseek-v4-pro:cloud",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=ollama_cloud_profile,
reasoning_config={"enabled": False},
base_url="https://ollama.com/v1",
provider_name="ollama-cloud",
)
assert "reasoning_effort" not in kwargs
class TestOllamaCloudAuxModel:
"""Ollama Cloud aux model is set on the profile."""
def test_profile_advertises_aux_model(self, ollama_cloud_profile):
assert ollama_cloud_profile.default_aux_model == "nemotron-3-nano:30b"
@@ -0,0 +1,235 @@
"""Unit tests for OpenCode Go reasoning-control wiring."""
from __future__ import annotations
import pytest
@pytest.fixture
def opencode_go_profile():
"""Resolve the registered OpenCode Go provider profile."""
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("opencode-go")
assert profile is not None, "opencode-go provider profile must be registered"
return profile
class TestOpenCodeGoKimiReasoning:
"""Kimi K2 models use Moonshot's thinking + reasoning_effort shape on OpenCode Go."""
def test_high_effort_emits_thinking_and_effort(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="kimi-k2.6",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "high"}
def test_disabled_emits_thinking_disabled_without_effort(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
model="kimi-k2.6",
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
def test_minimal_effort_enables_thinking_without_effort(self, opencode_go_profile):
# "minimal" is not a Moonshot-supported value — drop it, keep thinking on.
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "minimal"},
model="kimi-k2.6",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
@pytest.mark.parametrize(
"effort",
[
"xhigh",
"max",
],
)
def test_strong_efforts_clamp_to_high(self, opencode_go_profile, effort):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="moonshotai/kimi-k2.6",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "high"}
def test_low_and_medium_pass_through(self, opencode_go_profile):
for effort in ("low", "medium"):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="kimi-k2.5",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": effort}
def test_no_config_preserves_server_default(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config=None,
model="kimi-k2.6",
)
assert extra_body == {}
assert top_level == {}
class TestOpenCodeGoDeepSeekThinking:
"""DeepSeek V4 models use DeepSeek-style thinking controls on OpenCode Go."""
def test_high_effort_emits_thinking_and_effort(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="deepseek-v4-pro",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "high"}
def test_disabled_emits_thinking_disabled_without_effort(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"},
model="deepseek-v4-pro",
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
def test_no_config_emits_thinking_enabled_without_effort(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config=None,
model="deepseek-v4-pro",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
def test_minimal_effort_enables_thinking_without_effort(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "minimal"},
model="deepseek-v4-pro",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
def test_xhigh_and_max_normalize_to_max(self, opencode_go_profile):
for effort in ("xhigh", "max"):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="deepseek/deepseek-v4-pro",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "max"}
class TestOpenCodeGoGLM52Reasoning:
"""GLM-5.2 uses its native high/max reasoning_effort knob on OpenCode Go."""
def test_high_maps_to_high(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="glm-5.2",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "high"}
def test_low_and_medium_clamp_up_to_high(self, opencode_go_profile):
for effort in ("low", "medium", "minimal"):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="glm-5.2",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "high"}
def test_xhigh_and_max_map_to_max(self, opencode_go_profile):
for effort in ("xhigh", "max"):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="z-ai/glm-5.2",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "max"}
def test_disabled_leaves_server_default(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"},
model="glm-5.2",
)
assert extra_body == {}
assert top_level == {}
def test_no_config_leaves_server_default(self, opencode_go_profile):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config=None,
model="glm-5.2",
)
assert extra_body == {}
assert top_level == {}
@pytest.mark.parametrize("model", ["glm-5-2", "glm-5p2"])
def test_alias_spellings_recognized(self, opencode_go_profile, model):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "max"},
model=model,
)
assert top_level == {"reasoning_effort": "max"}
class TestOpenCodeGoModelGating:
"""Other OpenCode Go models must not receive Kimi/DeepSeek/GLM controls."""
@pytest.mark.parametrize(
"model",
[
"glm-5.1",
"glm-5",
"qwen3.6-plus",
"minimax-m2.7",
"deepseek-v3.1",
"deepseek-chat",
"",
None,
],
)
def test_non_target_models_emit_nothing(self, opencode_go_profile, model):
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model=model,
)
assert extra_body == {}
assert top_level == {}
class TestOpenCodeGoFullKwargsIntegration:
"""End-to-end transport kwargs include the profile-provided controls."""
def test_kimi_reasoning_reaches_extra_body_and_top_level(self, opencode_go_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="kimi-k2.6",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=opencode_go_profile,
reasoning_config={"enabled": True, "effort": "high"},
base_url="https://opencode.ai/zen/go/v1",
)
assert "extra_body" not in kwargs
assert kwargs["reasoning_effort"] == "high"
def test_deepseek_thinking_reaches_extra_body_and_top_level(
self, opencode_go_profile
):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="deepseek-v4-pro",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=opencode_go_profile,
reasoning_config={"enabled": True, "effort": "high"},
base_url="https://opencode.ai/zen/go/v1",
)
assert "extra_body" not in kwargs
assert kwargs["reasoning_effort"] == "high"
@@ -0,0 +1,250 @@
"""Unit tests for the Z.AI / GLM provider profile's reasoning wiring.
Z.AI's GLM-4.5-and-later chat models default to thinking-mode ON when the
request omits ``thinking``. Before the profile emitted the parameter,
``reasoning_config = {"enabled": False}`` was a silent no-op on the direct
Z.AI route — users who turned thinking off kept burning thinking tokens on
every turn (the desktop "thinking reverts to medium" report).
GLM-5.2 additionally exposes a native ``reasoning_effort`` knob with two
enabled levels (high / max) on the OpenAI-compatible ``/api/paas/v4``
endpoint; the Hermes effort scale is collapsed onto those.
These tests pin the profile's wire-shape contract so Z.AI requests stay
correctly shaped without going live.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def zai_profile():
"""Resolve the registered Z.AI profile through the real discovery path."""
# ``model_tools`` triggers plugin discovery on import, which is what
# registers the Z.AI profile in the global provider registry.
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("zai")
assert profile is not None, "zai provider profile must be registered"
return profile
class TestZaiThinkingWireShape:
"""``build_api_kwargs_extras`` produces Z.AI's exact wire format."""
def test_no_preference_omits_thinking(self, zai_profile):
"""No reasoning_config → omit ``thinking`` so the server default
applies (matches prior behavior for users with no preference)."""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config=None, model="glm-5"
)
assert extra_body == {}
assert top_level == {}
def test_enabled_sends_enabled_marker(self, zai_profile):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "medium"}, model="glm-5"
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
def test_explicitly_disabled_sends_disabled_marker(self, zai_profile):
"""``reasoning_config.enabled=False`` → ``thinking.type=disabled``.
The crucial bit is that the parameter is *sent* at all — GLM defaults
to thinking-on when ``thinking`` is absent, so an unsent disable
burns thinking tokens forever.
"""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="glm-5"
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
def test_no_effort_levels_leak_to_top_level(self, zai_profile):
"""Non-5.2 GLM models have no effort knob — never emit
``reasoning_effort`` for them (GLM-5.2 is the exception, below)."""
for effort in ("minimal", "low", "medium", "high", "xhigh"):
for model in ("glm-5", "glm-5.1", "glm-4.6"):
_, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}, model=model
)
assert top_level == {}
class TestZaiGLM52ReasoningEffort:
"""GLM-5.2's native ``reasoning_effort`` knob (two enabled levels)."""
def test_high_maps_to_high(self, zai_profile):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="glm-5.2",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {"reasoning_effort": "high"}
@pytest.mark.parametrize("effort", ["low", "medium", "minimal"])
def test_lower_efforts_clamp_up_to_high(self, zai_profile, effort):
"""GLM-5.2's minimum thinking level is high — lower Hermes levels
clamp onto it."""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="glm-5.2",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {"reasoning_effort": "high"}
@pytest.mark.parametrize("effort", ["xhigh", "max"])
def test_strong_efforts_map_to_max(self, zai_profile, effort):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="glm-5.2",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {"reasoning_effort": "max"}
def test_disabled_sends_no_effort(self, zai_profile):
"""Disabled reasoning still sends the thinking-off marker but never
an effort level."""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"},
model="glm-5.2",
)
assert extra_body == {"thinking": {"type": "disabled"}}
assert top_level == {}
def test_no_config_leaves_server_default(self, zai_profile):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config=None,
model="glm-5.2",
)
assert extra_body == {}
assert top_level == {}
def test_no_effort_sends_no_effort_level(self, zai_profile):
"""Enabled but no effort preference → thinking marker only; the
server picks its default effort."""
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True},
model="glm-5.2",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {}
@pytest.mark.parametrize(
"model",
[
"z-ai/glm-5.2",
"glm-5-2",
"glm-5p2",
"accounts/fireworks/models/glm-5p2",
"zai-org-glm-5-2",
],
)
def test_alias_spellings_recognized(self, zai_profile, model):
_, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "max"},
model=model,
)
assert top_level == {"reasoning_effort": "max"}
@pytest.mark.parametrize(
"model",
["glm-5.1", "glm-5", "glm-4.7", "glm-4-9b", "", None],
)
def test_non_glm_5_2_models_get_no_effort(self, zai_profile, model):
_, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model=model,
)
assert top_level == {}
class TestZaiModelGating:
"""GLM 4.5+ get thinking; earlier GLM models are left untouched."""
@pytest.mark.parametrize(
"model",
[
"glm-4.5",
"glm-4.5-air",
"glm-4.5-flash",
"glm-4.6",
"glm-5",
"glm-5.2",
"GLM-5", # case-insensitive
],
)
def test_thinking_capable_models_emit_thinking(self, zai_profile, model):
extra_body, _ = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model=model
)
assert extra_body == {"thinking": {"type": "disabled"}}
@pytest.mark.parametrize(
"model",
[
"glm-4-9b", # pre-4.5, no thinking param
"glm-4",
"glm-3-turbo",
"", # bare/unknown
None, # missing
"charglm-3", # non-GLM-versioned id
],
)
def test_non_thinking_models_emit_nothing(self, zai_profile, model):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model=model
)
assert extra_body == {}
assert top_level == {}
class TestZaiFullKwargsIntegration:
"""End-to-end: the transport's full kwargs carry the reasoning wiring."""
def test_disabled_reaches_the_wire(self, zai_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="glm-5",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=zai_profile,
reasoning_config={"enabled": False},
base_url="https://api.z.ai/api/paas/v4",
provider_name="zai",
)
assert kwargs["extra_body"]["thinking"] == {"type": "disabled"}
def test_no_preference_keeps_wire_clean(self, zai_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="glm-5",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=zai_profile,
reasoning_config=None,
base_url="https://api.z.ai/api/paas/v4",
provider_name="zai",
)
assert "thinking" not in kwargs.get("extra_body", {})
def test_glm_5_2_effort_reaches_top_level(self, zai_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="glm-5.2",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=zai_profile,
reasoning_config={"enabled": True, "effort": "max"},
base_url="https://api.z.ai/api/paas/v4",
provider_name="zai",
)
assert kwargs["reasoning_effort"] == "max"
assert kwargs["extra_body"]["thinking"] == {"type": "enabled"}
+574
View File
@@ -0,0 +1,574 @@
"""Tests for the Photon auth module (device login + dashboard API)."""
from __future__ import annotations
import json
import os
from base64 import b64encode
from pathlib import Path
from typing import Any, Dict
import pytest
from plugins.platforms.photon import auth as photon_auth
# ---------------------------------------------------------------------------
# Fake httpx — we don't want to hit the real Photon API in unit tests.
class _FakeResponse:
def __init__(
self,
*,
status: int = 200,
json_body: Any = None,
headers: Dict[str, str] | None = None,
text: str = "",
) -> None:
self.status_code = status
self._json = json_body if json_body is not None else {}
self.headers = headers or {}
self.text = text
def json(self) -> Any:
return self._json
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
_PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_SPECTRUM_HOST",
"PHOTON_ALLOWED_USERS",
"PHOTON_HOME_CHANNEL",
)
@pytest.fixture
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for key in _PHOTON_ENV:
monkeypatch.delenv(key, raising=False)
yield home
# save_env_value() mutates os.environ directly, so scrub any leakage.
for key in _PHOTON_ENV:
os.environ.pop(key, None)
# ---------------------------------------------------------------------------
# Credential storage
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
photon_auth.store_photon_token("abc123def456")
assert photon_auth.load_photon_token() == "abc123def456"
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
def test_store_project_credentials_round_trip(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
# Don't touch .env / os.environ here — exercise the auth.json path.
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
photon_auth.store_project_credentials(
spectrum_project_id="sp-123",
project_secret="secret-key",
dashboard_project_id="dash-456",
name="Hermes Agent",
)
for key in _PHOTON_ENV:
monkeypatch.delenv(key, raising=False)
sid, secret = photon_auth.load_project_credentials()
assert sid == "sp-123"
assert secret == "secret-key"
# Post-unification the management id resolves to the Spectrum id, not the
# stored dashboard id — so a pre-backfill diverged install (whose old
# dashboard id was rewritten and now 404s) still reaches the live row.
assert photon_auth.load_dashboard_project_id() == "sp-123"
def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
photon_auth.store_project_credentials(
spectrum_project_id="sp-789",
project_secret="sek-ret",
dashboard_project_id="dash-1",
)
env_text = (tmp_hermes_home / ".env").read_text()
assert "PHOTON_PROJECT_ID=sp-789" in env_text
assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text
def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None:
photon_auth.store_user_numbers(
phone_number="+15551234567",
assigned_phone_number="+16282679185",
user_id="user-uuid",
dashboard_project_id="dash-uuid",
)
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned == "+16282679185"
summary = photon_auth.credential_summary()
assert summary["phone_number"] == "+15551234567"
assert summary["assigned_phone_number"] == "+16282679185"
rendered: list[str] = []
photon_auth.print_credential_summary(rendered.append)
assert " my number : +15551234567" in rendered[0]
assert " assigned number : +16282679185" in rendered[0]
def test_load_user_numbers_falls_back_to_home_channel(
tmp_hermes_home: Path,
) -> None:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned is None
def test_refresh_user_numbers_reads_existing_assignment(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
photon_auth.store_user_numbers(phone_number="+15551234567")
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
assert kwargs.get("headers", {}).get("Authorization") == (
"Basic " + b64encode(b"sp:secret").decode("ascii")
)
assert url.endswith("/projects/sp/users/")
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
"id": "user-uuid",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
assert phone == "+15551234567"
assert assigned == "+16282679185"
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
def test_load_project_credentials_env_override(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
photon_auth.store_project_credentials(
spectrum_project_id="from-file", project_secret="secret-file",
)
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
sid, secret = photon_auth.load_project_credentials()
assert sid == "from-env"
assert secret == "secret-env"
# ---------------------------------------------------------------------------
# Device login flow
def test_request_device_code_uses_photon_cli(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["body"] = kwargs.get("json")
return _FakeResponse(json_body={
"device_code": "dev-code-xyz",
"user_code": "ABCD-1234",
"verification_uri": "https://app.photon.codes/device",
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
"expires_in": 600,
"interval": 5,
})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
code = photon_auth.request_device_code()
assert code.device_code == "dev-code-xyz"
assert code.user_code == "ABCD-1234"
assert "/api/auth/device/code" in captured["url"]
# Hosted Photon allowlists registered device clients — an unregistered
# client_id is rejected with 400 invalid_client. We use Photon's published
# CLI device client and send the standard scope.
assert captured["body"]["client_id"] == "photon-cli"
assert captured["body"]["scope"] == "openid profile email"
def _device_code() -> "photon_auth.DeviceCode":
return photon_auth.DeviceCode(
device_code="d", user_code="u",
verification_uri="https://x", verification_uri_complete=None,
expires_in=10, interval=0,
)
def test_poll_for_token_body_access_token(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(status=200, json_body={"access_token": "tok-body"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-body"
def test_poll_for_token_session_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(status=200, json_body={"session": {"access_token": "tok-sess"}})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-sess"
def test_poll_for_token_header_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(status=200, json_body={}, headers={"set-auth-token": "tok-hdr"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-hdr"
def test_poll_for_token_pending_then_success(monkeypatch: pytest.MonkeyPatch) -> None:
calls = {"n": 0}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
calls["n"] += 1
if calls["n"] == 1:
return _FakeResponse(status=400, json_body={"error": "authorization_pending"})
return _FakeResponse(status=200, json_body={"access_token": "tok-eventual"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=5) == "tok-eventual"
assert calls["n"] == 2
def test_poll_for_token_access_denied(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(status=400, json_body={"error": "access_denied"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
with pytest.raises(RuntimeError, match="access_denied"):
photon_auth.poll_for_token(_device_code(), interval=0, timeout=2)
# ---------------------------------------------------------------------------
# Projects
def test_list_projects_unwraps_list(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body=[{"id": "p1", "name": "Hermes Agent"}])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
projects = photon_auth.list_projects("tok")
assert projects[0]["id"] == "p1"
def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"data": [
{"id": "p1", "name": "Other"},
{"id": "p2", "name": "hermes agent"},
]})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
proj = photon_auth.find_project_by_name("tok", "Hermes Agent")
assert proj is not None and proj["id"] == "p2"
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["body"] = kwargs.get("json")
captured["headers"] = kwargs.get("headers")
return _FakeResponse(json_body={"success": True, "id": "new-proj"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
data = photon_auth.create_project("tok", name="Hermes Agent")
assert data["id"] == "new-proj"
# Spectrum is always provisioned at create-time; the field was dropped
# from the API schema, so we must not send it.
assert "spectrum" not in captured["body"]
assert captured["body"]["name"] == "Hermes Agent"
assert captured["headers"]["Authorization"] == "Bearer tok"
assert captured["url"].endswith("/api/projects")
def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"success": True})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
with pytest.raises(RuntimeError, match="project id"):
photon_auth.create_project("tok")
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
assert url.endswith("/regenerate-secret")
return _FakeResponse(json_body={"success": True, "projectSecret": "rotated"})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
assert photon_auth.regenerate_project_secret("tok", "p") == "rotated"
# ---------------------------------------------------------------------------
# Users
def test_create_user_rejects_invalid_phone() -> None:
with pytest.raises(ValueError, match="E.164"):
photon_auth.create_user("proj", "secret", phone_number="not-a-number")
def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
captured["url"] = url
captured["body"] = kwargs.get("json")
captured["headers"] = kwargs.get("headers")
return _FakeResponse(json_body={"succeed": True, "data": {
"id": "user-uuid", "phoneNumber": "+15551234567",
}})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567")
assert user["id"] == "user-uuid"
assert captured["body"]["type"] == "shared"
assert captured["body"]["phoneNumber"] == "+15551234567"
assert captured["headers"]["Authorization"] == (
"Basic " + b64encode(b"proj-id:secret").decode("ascii")
)
assert captured["url"].endswith("/projects/proj-id/users/")
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
posted = {"n": 0}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
"id": "u1",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
posted["n"] += 1
return _FakeResponse(json_body={"success": True, "user": {}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
# Same number, different formatting — should match and NOT create.
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
)
assert created is False
assert user["id"] == "u1"
assert posted["n"] == 0
# The reused user carries the assigned iMessage line ("TEXTS ON").
assert photon_auth.user_assigned_line(user) == "+16282679185"
def test_user_assigned_line() -> None:
assert (
photon_auth.user_assigned_line({"assignedPhoneNumber": "+16282679185"})
== "+16282679185"
)
# Own number present but no assignment yet (e.g. freshly created user).
assert photon_auth.user_assigned_line({"phoneNumber": "+15551234567"}) is None
assert photon_auth.user_assigned_line({"assignedPhoneNumber": ""}) is None
assert photon_auth.user_assigned_line({}) is None
assert photon_auth.user_assigned_line(None) is None
def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": []}})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"id": "u-new"}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
)
assert created is True
assert user["id"] == "u-new"
# ---------------------------------------------------------------------------
# Lines (assigned number)
def test_get_imessage_line_returns_existing(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body=[
{"id": "l1", "platform": "imessage", "phoneNumber": "+15559999999", "status": "active"},
])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
line = photon_auth.get_imessage_line("tok", "proj")
assert line is not None and line["phoneNumber"] == "+15559999999"
def test_get_imessage_line_provisions_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
added = {"n": 0}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body=[])
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
added["n"] += 1
assert kwargs.get("json", {}).get("platform") == "imessage"
return _FakeResponse(json_body={"success": True, "line": {
"id": "l-new", "platform": "imessage", "phoneNumber": "+15558888888",
}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
line = photon_auth.get_imessage_line("tok", "proj")
assert added["n"] == 1
assert line["phoneNumber"] == "+15558888888"
# ---------------------------------------------------------------------------
# Credential summary (no secret leakage)
def test_credential_summary_no_secret_leak(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
photon_auth.store_project_credentials(
spectrum_project_id="sp-uuid",
project_secret="secret-bbbbbbbbbbb",
dashboard_project_id="dash-uuid",
)
summary = photon_auth.credential_summary()
blob = "\n".join(summary.values())
assert "token-aaaa" not in blob
assert "secret-bbbb" not in blob
assert summary["device_token"].startswith("")
assert summary["project_key"].startswith("")
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
assert summary["project_id"] == "sp-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
# ---------------------------------------------------------------------------
# Device-token candidate extraction + dashboard validation.
def test_device_response_candidates_covers_known_shapes() -> None:
candidates = photon_auth._device_response_token_candidates(
{
"access_token": "tok-snake",
"accessToken": "tok-camel",
"data": {"access_token": "tok-data"},
},
headers={"set-auth-token": "Bearer tok-header"},
)
by_source = {c.source: c.token for c in candidates}
assert by_source["access_token"] == "tok-snake"
assert by_source["accessToken"] == "tok-camel"
assert by_source["data.access_token"] == "tok-data"
# "Bearer " prefix is stripped from the header value.
assert by_source["set-auth-token"] == "tok-header"
def test_device_response_candidates_dedupes() -> None:
candidates = photon_auth._device_response_token_candidates(
{"access_token": "same", "accessToken": "same"},
)
assert [c.token for c in candidates] == ["same"]
def test_validate_photon_token_rejects_unrecognized_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/get-session"):
return _FakeResponse(json_body={}) # no "user" key
return _FakeResponse(json_body=[])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
with pytest.raises(photon_auth.PhotonDashboardAuthError):
photon_auth.validate_photon_token("some-token")
def test_validate_photon_token_rejects_project_api_denial(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/get-session"):
return _FakeResponse(json_body={"user": {"id": "u1"}})
return _FakeResponse(status=403) # project API rejects
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
with pytest.raises(photon_auth.PhotonDashboardAuthError):
photon_auth.validate_photon_token("some-token")
def test_login_device_flow_validates_before_persisting(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/device/code"):
return _FakeResponse(json_body={
"device_code": "dev", "user_code": "AAAA",
"verification_uri": "https://app.photon.codes/device",
"verification_uri_complete": None,
"expires_in": 600, "interval": 0,
})
# device/token approval
return _FakeResponse(json_body={"access_token": "good-token"})
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/get-session"):
return _FakeResponse(json_body={"user": {"id": "u1"}})
return _FakeResponse(json_body=[]) # projects OK
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
token = photon_auth.login_device_flow(open_browser=False)
assert token == "good-token"
assert photon_auth.load_photon_token() == "good-token"
def test_login_device_flow_raises_when_token_invalid(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
if url.endswith("/api/auth/device/code"):
return _FakeResponse(json_body={
"device_code": "dev", "user_code": "AAAA",
"verification_uri": "https://app.photon.codes/device",
"verification_uri_complete": None,
"expires_in": 600, "interval": 0,
})
return _FakeResponse(json_body={"access_token": "bad-token"})
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
return _FakeResponse(status=401) # session lookup rejects
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
with pytest.raises(photon_auth.PhotonDashboardAuthError):
photon_auth.login_device_flow(open_browser=False)
# A token that failed validation must never be persisted.
assert photon_auth.load_photon_token() is None
@@ -0,0 +1,364 @@
"""Inbound dispatch + dedup tests for PhotonAdapter.
These bypass the loopback HTTP stream — they call ``_dispatch_inbound`` /
``_on_inbound_line`` / ``_is_duplicate`` directly, exercising the
sidecar-event parsing without spawning the Node sidecar or binding ports.
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any, Dict, List
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _dm_event(text: str, msg_id: str = "spc-msg-abc") -> Dict[str, Any]:
return {
"messageId": msg_id,
"platform": "iMessage",
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": text},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_event("hello world"))
assert len(captured) == 1
event = captured[0]
assert event.text == "hello world"
assert event.message_type == MessageType.TEXT
assert event.message_id == "spc-msg-abc"
src = event.source
assert src is not None
assert src.platform == Platform("photon")
assert src.chat_id == "+15551234567"
assert src.chat_type == "dm"
assert src.user_id == "+15551234567"
@pytest.mark.asyncio
async def test_dispatch_group_type(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = {
"messageId": "spc-msg-grp",
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": "hi group"},
"timestamp": "2026-05-14T19:06:32.000Z",
}
await adapter._dispatch_inbound(event)
assert captured[0].source.chat_type == "group"
# A real 1x1 transparent PNG (passes base.py's _looks_like_image magic check).
_PNG_1X1_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def _attachment_event(
content: Dict[str, Any], msg_id: str = "spc-msg-att"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "attachment", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
def _voice_event(
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "voice", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_dispatch_attachment_without_bytes_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No inline ``data`` (over cap / failed sidecar read) -> text marker, no media."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = _attachment_event(
{"name": "IMG_4127.HEIC", "mimeType": "image/heic", "size": 12345}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert "Photon attachment received" in ev.text
assert "IMG_4127.HEIC" in ev.text
assert ev.message_type == MessageType.PHOTO
assert ev.media_urls == []
assert ev.media_types == []
@pytest.mark.asyncio
async def test_dispatch_attachment_downloads_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Inline base64 image bytes are decoded, cached, and exposed as media."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = base64.b64decode(_PNG_1X1_B64)
event = _attachment_event(
{
"name": "photo.png",
"mimeType": "image/png",
"size": len(raw),
"data": _PNG_1X1_B64,
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.PHOTO
assert ev.media_types == ["image/png"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
assert ev.text == "(attachment)"
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_group_preserves_text_and_attachment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Spectrum group content from a mixed text+image iMessage must not drop text."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = base64.b64decode(_PNG_1X1_B64)
event = _attachment_event(
{},
msg_id="spc-msg-mixed",
)
event["content"] = {
"type": "group",
"items": [
{
"id": "p:0/spc-msg-mixed",
"content": {"type": "text", "text": "请分析这张图的重点"},
},
{
"id": "p:1/spc-msg-mixed",
"content": {
"type": "attachment",
"name": "photo.png",
"mimeType": "image/png",
"size": len(raw),
"data": _PNG_1X1_B64,
"encoding": "base64",
},
},
],
}
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.text == "请分析这张图的重点"
assert ev.message_type == MessageType.PHOTO
assert ev.media_types == ["image/png"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_voice_downloads_audio(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Inbound Spectrum voice content is cached and routed to auto-STT."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = b"OggS" + b"\x00" * 32
event = _voice_event(
{
"name": "note.ogg",
"mimeType": "audio/ogg",
"duration": 7,
"size": len(raw),
"data": base64.b64encode(raw).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.VOICE
assert ev.media_types == ["audio/ogg"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
assert ev.text == "(voice)"
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_voice_without_bytes_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Metadata-only voice still tells the agent a voice note arrived."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = _voice_event(
{"name": "note.m4a", "mimeType": "audio/mp4", "duration": 12, "size": 12345}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert "Photon voice received" in ev.text
assert "note.m4a" in ev.text
assert "duration: 12s" in ev.text
assert ev.message_type == MessageType.VOICE
assert ev.media_urls == []
assert ev.media_types == []
@pytest.mark.asyncio
async def test_dispatch_attachment_downloads_document(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Non-image attachments route through the document cache as DOCUMENT."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = b"%PDF-1.4 hermes test document"
event = _attachment_event(
{
"name": "report.pdf",
"mimeType": "application/pdf",
"size": len(raw),
"data": base64.b64encode(raw).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.DOCUMENT
assert ev.media_types == ["application/pdf"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
assert ev.text == "(attachment)"
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_on_inbound_line_dispatches_and_dedups(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
line = json.dumps(_dm_event("ping", msg_id="dup-1"))
await adapter._on_inbound_line(line)
await adapter._on_inbound_line(line) # same messageId -> deduped
assert len(captured) == 1
assert captured[0].text == "ping"
@pytest.mark.asyncio
async def test_on_inbound_line_ignores_bad_json(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
await adapter._on_inbound_line("{not json")
assert captured == []
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
assert adapter._is_duplicate("id-1") is False
assert adapter._is_duplicate("id-1") is True
assert adapter._is_duplicate("id-2") is False
assert adapter._is_duplicate("id-1") is True # still dup
def test_is_duplicate_hard_size_bound(monkeypatch: pytest.MonkeyPatch) -> None:
# A burst of unique ids within the window must not grow the dedup map past
# its bound — evict oldest (LRU), not only expired entries.
import plugins.platforms.photon.adapter as ad
monkeypatch.setattr(ad, "_DEDUP_MAX_SIZE", 5)
adapter = _make_adapter(monkeypatch)
for i in range(100):
adapter._is_duplicate(f"id-{i}")
assert len(adapter._seen_messages) <= 5
assert adapter._is_duplicate("id-99") is True # recent still deduped
assert adapter._is_duplicate("id-0") is False # oldest evicted
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
# If no node binary on PATH the adapter should refuse to start.
from plugins.platforms.photon import adapter as adapter_mod
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
assert adapter_mod.check_requirements() is False
@@ -0,0 +1,129 @@
"""Markdown handling tests for PhotonAdapter.
Markdown is on by default (the sidecar sends it via spectrum-ts'
``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false``
reverts to the stripped-plain-text path.
"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
_MD = "**bold** and `code`"
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
def test_format_message_passthrough_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
assert adapter.format_message(_MD) == _MD
def test_format_message_strips_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
adapter = _make_adapter(monkeypatch)
assert adapter.format_message(_MD) == "bold and code"
def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
assert _make_adapter(monkeypatch).supports_code_blocks is True
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
assert _make_adapter(monkeypatch).supports_code_blocks is False
@pytest.mark.asyncio
async def test_sidecar_send_includes_markdown_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+15551234567", _MD)
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
assert body["text"] == _MD # passed through unstripped
@pytest.mark.asyncio
async def test_sidecar_send_omits_format_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Old-sidecar compat: the key is absent, not "text", when disabled."""
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+15551234567", _MD)
_, body = calls[0]
assert "format" not in body
assert body["text"] == "bold and code"
@pytest.mark.asyncio
async def test_standalone_send_includes_markdown_format(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
posted: List[Tuple[str, Dict[str, Any]]] = []
class _Resp:
status_code = 200
@staticmethod
def json() -> Dict[str, Any]:
return {"ok": True, "messageId": "m-9"}
class _FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url: str, json: Dict[str, Any], headers=None):
posted.append((url, json))
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
cfg = PlatformConfig(enabled=True, token="", extra={})
result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD)
assert result.get("success") is True
assert posted[0][1]["format"] == "markdown"
@@ -0,0 +1,138 @@
"""Group-chat mention-gating tests for PhotonAdapter.
Parity with the BlueBubbles iMessage channel: when ``require_mention`` is
enabled, group messages are dropped unless they hit a wake-word pattern,
and the leading wake word is stripped from the ones that pass. DMs are
never gated.
These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert
on what reaches ``handle_message``.
"""
from __future__ import annotations
from typing import List
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra=extra or {})
return PhotonAdapter(cfg)
def _group_payload(text: str) -> dict:
return {
"messageId": f"grp-{abs(hash(text))}",
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": text},
"timestamp": "2026-05-14T19:06:32.000Z",
}
def _dm_payload(text: str) -> dict:
return {
"messageId": f"dm-{abs(hash(text))}",
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "text", "text": text},
"timestamp": "2026-05-14T19:06:32.000Z",
}
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
assert adapter.require_mention is False
# Defaults compile to the two Hermes wake-word patterns.
assert len(adapter._mention_patterns) == 2
@pytest.mark.asyncio
async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("just chatting, no wake word"))
assert captured == []
@pytest.mark.asyncio
async def test_group_message_passes_and_strips_wake_word(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("Hermes what's the weather"))
assert len(captured) == 1
# Leading wake word stripped before dispatch.
assert captured[0].text == "what's the weather"
@pytest.mark.asyncio
async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_payload("no wake word here"))
assert len(captured) == 1
assert captured[0].text == "no wake word here"
@pytest.mark.asyncio
async def test_require_mention_off_passes_group_messages(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch) # require_mention defaults off
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_group_payload("plain group chatter"))
assert len(captured) == 1
assert captured[0].text == "plain group chatter"
def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": [r"(?<![\w@])@?amos\b[,:\-]?"]},
)
assert adapter.require_mention is True
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("amos help me") is True
assert adapter._message_matches_mention_patterns("hermes help me") is False
def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b")
cfg = PlatformConfig(enabled=True, token="", extra={})
adapter = PhotonAdapter(cfg)
assert adapter.require_mention is True
assert len(adapter._mention_patterns) == 2
assert adapter._message_matches_mention_patterns("hey bot") is True
def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(
monkeypatch,
extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]},
)
# Bad regex dropped, good one kept.
assert len(adapter._mention_patterns) == 1
assert adapter._message_matches_mention_patterns("a good thing") is True
@@ -0,0 +1,255 @@
"""Outbound-media tests for PhotonAdapter.
Photon ships outbound attachments via spectrum-ts' ``attachment()`` /
``voice()`` content builders, reached through the Node sidecar's
``/send-attachment`` endpoint. These tests stub ``_sidecar_call`` so we
can assert the endpoint + body shape each ``send_*`` override produces
without spawning Node or binding ports.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
"""Replace ``_sidecar_call`` with a recorder that returns a fixed id."""
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
@pytest.fixture()
def real_file(tmp_path) -> str:
p = tmp_path / "photo.jpg"
p.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
return str(p)
def _patch_safe_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make path validation a passthrough so tmp files outside the cache pass."""
monkeypatch.setattr(
PhotonAdapter,
"validate_media_delivery_path",
staticmethod(lambda p: p if os.path.exists(p) else None),
)
@pytest.mark.asyncio
async def test_send_image_file_hits_attachment_endpoint(
monkeypatch: pytest.MonkeyPatch, real_file: str
) -> None:
_patch_safe_path(monkeypatch)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
result = await adapter.send_image_file(
"any;-;+15551234567", real_file, caption="look"
)
assert result.success is True
assert result.message_id == "msg-123"
assert len(calls) == 1
path, body = calls[0]
assert path == "/send-attachment"
assert body["spaceId"] == "any;-;+15551234567"
assert body["path"] == real_file
assert body["kind"] == "attachment"
assert body["caption"] == "look"
assert body["mimeType"] == "image/jpeg" # inferred from .jpg
@pytest.mark.asyncio
async def test_send_voice_marks_kind_voice(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
_patch_safe_path(monkeypatch)
audio = tmp_path / "note.m4a"
audio.write_bytes(b"fake-audio")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
result = await adapter.send_voice("any;-;+1", str(audio))
assert result.success is True
path, body = calls[0]
assert path == "/send-attachment"
assert body["kind"] == "voice"
@pytest.mark.asyncio
async def test_send_document_passes_filename(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
_patch_safe_path(monkeypatch)
doc = tmp_path / "report.pdf"
doc.write_bytes(b"%PDF-1.4 fake")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send_document("any;-;+1", str(doc), file_name="Q3.pdf")
_, body = calls[0]
assert body["kind"] == "attachment"
assert body["name"] == "Q3.pdf"
assert body["mimeType"] == "application/pdf"
@pytest.mark.asyncio
async def test_send_video_passes_through(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
_patch_safe_path(monkeypatch)
vid = tmp_path / "clip.mp4"
vid.write_bytes(b"fake-mp4")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send_video("any;+;groupguid", str(vid), caption="watch")
_, body = calls[0]
assert body["kind"] == "attachment"
assert body["caption"] == "watch"
@pytest.mark.asyncio
async def test_send_image_url_caches_then_sends_attachment(
monkeypatch: pytest.MonkeyPatch, real_file: str
) -> None:
_patch_safe_path(monkeypatch)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
async def _fake_cache(url: str, *a, **k) -> str:
assert url == "https://example.com/cat.jpg"
return real_file
import gateway.platforms.base as base_mod
monkeypatch.setattr(base_mod, "cache_image_from_url", _fake_cache)
result = await adapter.send_image(
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
)
assert result.success is True
path, body = calls[0]
assert path == "/send-attachment"
assert body["path"] == real_file
assert body["caption"] == "cat"
@pytest.mark.asyncio
async def test_send_image_url_fetch_failure_falls_back_to_text(
monkeypatch: pytest.MonkeyPatch
) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
async def _boom(url: str, *a, **k) -> str:
raise RuntimeError("network down")
import gateway.platforms.base as base_mod
monkeypatch.setattr(base_mod, "cache_image_from_url", _boom)
result = await adapter.send_image(
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
)
# Fallback path: base send_image() routes to send() → /send (text).
assert result.success is True
assert calls[0][0] == "/send"
assert "https://example.com/cat.jpg" in calls[0][1]["text"]
@pytest.mark.asyncio
async def test_send_attachment_rejects_unsafe_path(
monkeypatch: pytest.MonkeyPatch
) -> None:
# Default validation (no passthrough patch) should reject a nonexistent /
# traversal path, returning a failed SendResult without calling the sidecar.
monkeypatch.setattr(
PhotonAdapter,
"validate_media_delivery_path",
staticmethod(lambda p: None),
)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
result = await adapter.send_image_file("any;-;+1", "/etc/passwd")
assert result.success is False
assert "unsafe" in (result.error or "")
assert calls == [] # never reached the sidecar
@pytest.mark.asyncio
async def test_standalone_send_text_then_attachments(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
_patch_safe_path(monkeypatch)
img = tmp_path / "a.png"
img.write_bytes(b"\x89PNG fake")
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
posted: List[Tuple[str, Dict[str, Any]]] = []
class _Resp:
status_code = 200
@staticmethod
def json() -> Dict[str, Any]:
return {"ok": True, "messageId": "m-9"}
class _FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url: str, json: Dict[str, Any], headers=None):
posted.append((url, json))
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
cfg = PlatformConfig(enabled=True, token="", extra={})
result = await photon_adapter._standalone_send(
cfg,
"any;-;+1",
"hello",
media_files=[(str(img), False)],
)
assert result.get("success") is True
# First call is the text /send, second is /send-attachment.
assert posted[0][0].endswith("/send")
assert posted[0][1]["text"] == "hello"
assert posted[1][0].endswith("/send-attachment")
assert posted[1][1]["path"] == str(img)
assert posted[1][1]["kind"] == "attachment"
assert posted[1][1]["mimeType"] == "image/png"
@@ -0,0 +1,236 @@
"""Photon adapter resilience to transient Spectrum/Envoy upstream overflow.
Covers the three behaviors that let the adapter ride through a Photon
"reset reason: overflow" event instead of degrading delivery and silently
dying (issue #50185):
1. ``_is_retryable_error`` classifies the Envoy/sidecar overflow strings as
retryable so ``_send_with_retry`` actually engages its backoff loop.
2. ``send_typing`` is rate-gated per chat, and ``stop_typing`` resets the
gate so the next turn's typing indicator fires immediately.
3. ``_supervise_sidecar`` detects an unexpected sidecar exit and raises a
``retryable=True`` fatal so the gateway reconnect watcher revives the
platform — instead of returning silently and leaving ``_inbound_loop``
spinning against a dead port.
4. ``_monitor_sidecar_health`` promotes degraded upstream stream health
reported by ``/healthz`` into the same retryable reconnect path.
No Node sidecar is spawned and no ports are bound.
"""
from __future__ import annotations
from typing import Any, Dict
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
# -- Gap 1: retryable classification of overflow errors ---------------------
@pytest.mark.parametrize(
"error",
[
"UNAVAILABLE: internal sidecar error",
"upstream connect error or disconnect/reset before headers",
"reset reason: overflow",
# Case-insensitive: real strings arrive with mixed case.
"Internal Sidecar Error",
],
)
def test_overflow_strings_classified_retryable(error: str) -> None:
assert PhotonAdapter._is_retryable_error(error) is True
def test_unrelated_error_not_retryable() -> None:
# A genuine permanent failure must NOT be retried.
assert PhotonAdapter._is_retryable_error("400 bad request: invalid spaceId") is False
assert PhotonAdapter._is_retryable_error(None) is False
def test_base_network_patterns_still_match() -> None:
# The override delegates to the base classifier first, so generic
# network strings keep working.
assert PhotonAdapter._is_retryable_error("ConnectError: connection refused") is True
# -- Gap 2: typing-indicator cooldown ---------------------------------------
@pytest.mark.asyncio
async def test_typing_cooldown_suppresses_rapid_repeats(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
calls: list[Dict[str, Any]] = []
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
calls.append(payload)
return {"ok": True}
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
# First call fires; immediate repeats are suppressed by the cooldown.
await adapter.send_typing("chat-1")
await adapter.send_typing("chat-1")
await adapter.send_typing("chat-1")
assert len(calls) == 1
@pytest.mark.asyncio
async def test_typing_cooldown_is_per_chat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
calls: list[str] = []
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
calls.append(payload["spaceId"])
return {"ok": True}
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
# Different chats have independent cooldowns.
await adapter.send_typing("chat-1")
await adapter.send_typing("chat-2")
assert calls == ["chat-1", "chat-2"]
@pytest.mark.asyncio
async def test_stop_typing_resets_cooldown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
starts = 0
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
nonlocal starts
if payload.get("state") == "start":
starts += 1
return {"ok": True}
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
# A start, then a stop (end of turn), then a start for the next turn must
# fire immediately — the cooldown only suppresses rapid consecutive starts
# without an intervening stop.
await adapter.send_typing("chat-1")
await adapter.stop_typing("chat-1")
await adapter.send_typing("chat-1")
assert starts == 2
# -- Gap 3: sidecar crash detection -----------------------------------------
class _EofStdout:
"""A proc.stdout whose readline() reports immediate EOF (dead sidecar)."""
def readline(self) -> bytes:
return b""
class _DeadProc:
"""Minimal subprocess.Popen stand-in for a sidecar that has exited."""
def __init__(self, exit_code: int = 1) -> None:
self.stdout = _EofStdout()
self.stdin = None
self._exit_code = exit_code
def poll(self) -> int:
return self._exit_code
@pytest.mark.asyncio
async def test_unexpected_sidecar_exit_raises_retryable_fatal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
# Simulate a live session whose sidecar then dies underneath it.
adapter._inbound_running = True
notified: list[bool] = []
async def _fake_notify() -> None:
notified.append(True)
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
await adapter._supervise_sidecar(_DeadProc(exit_code=137)) # type: ignore[arg-type]
assert adapter.has_fatal_error is True
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
# retryable=True routes the platform into the reconnect watcher rather
# than crashing the whole gateway.
assert adapter.fatal_error_retryable is True
assert adapter._running is False
assert notified == [True]
@pytest.mark.asyncio
async def test_clean_shutdown_does_not_raise_fatal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
# disconnect() sets _inbound_running = False before stopping the sidecar,
# so the detection block must NOT fire on a clean shutdown.
adapter._inbound_running = False
notified: list[bool] = []
async def _fake_notify() -> None:
notified.append(True)
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
await adapter._supervise_sidecar(_DeadProc(exit_code=0)) # type: ignore[arg-type]
assert adapter.has_fatal_error is False
assert notified == []
@pytest.mark.asyncio
async def test_degraded_stream_health_raises_retryable_fatal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
adapter._sidecar_health_interval = 0.0
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
assert path == "/healthz"
return {
"ok": True,
"stream": {
"ok": False,
"state": "degraded",
"degradedForMs": 120000,
"lastIssue": "[spectrum.stream] stream interrupted; reconnecting",
},
}
notified: list[bool] = []
async def _fake_notify() -> None:
notified.append(True)
adapter._inbound_running = False
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
await adapter._monitor_sidecar_health()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
assert adapter.fatal_error_retryable is True
assert notified == [True]
@@ -0,0 +1,303 @@
"""Reaction (tapback) tests for PhotonAdapter.
Outbound reactions go through the sidecar's ``/react`` / ``/unreact``
endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body
shape. Inbound reaction events are fed straight to ``_dispatch_inbound``.
Neither path spawns the Node sidecar or binds ports.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
from plugins.platforms.photon.adapter import PhotonAdapter
_EYES = "\U0001f440"
_THUMBS_UP = "\U0001f44d"
_THUMBS_DOWN = "\U0001f44e"
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
return calls
def _capture_handled(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
) -> List[MessageEvent]:
captured: List[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _message_event(adapter: PhotonAdapter) -> MessageEvent:
return MessageEvent(
text="hi",
message_type=MessageType.TEXT,
source=adapter.build_source(
chat_id="+15551234567",
chat_name="+15551234567",
chat_type="dm",
user_id="+15551234567",
user_name=None,
),
message_id="target-msg-1",
timestamp=datetime.now(tz=timezone.utc),
)
def _reaction_event(
emoji: str = "❤️",
target_id: str = "bot-msg-1",
target_direction: Any = "outbound",
space_type: str = "dm",
target_text: Any = "the bot's earlier reply",
) -> Dict[str, Any]:
return {
"messageId": "reaction-evt-1",
"platform": "iMessage",
"space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {
"type": "reaction",
"emoji": emoji,
"targetMessageId": target_id,
"targetDirection": target_direction,
# The sidecar always emits this key (hydrated reaction target);
# null when the reacted-to message carried no text.
"targetText": target_text,
},
"timestamp": "2026-06-11T10:00:00.000Z",
}
# -- Outbound: /react and /unreact body shapes ------------------------------
@pytest.mark.asyncio
async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES)
assert ok is True
assert calls == [
(
"/react",
{
"spaceId": "+15551234567",
"messageId": "target-msg-1",
"emoji": _EYES,
},
)
]
@pytest.mark.asyncio
async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
ok = await adapter._remove_reaction("+15551234567", "target-msg-1")
assert ok is True
assert calls == [
("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"})
]
@pytest.mark.asyncio
async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError("sidecar down")
adapter._sidecar_call = _boom # type: ignore[assignment]
assert await adapter._add_reaction("+1", "m", _EYES) is False
assert await adapter._remove_reaction("+1", "m") is False
# -- Lifecycle hooks ---------------------------------------------------------
@pytest.mark.asyncio
async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHOTON_REACTIONS", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
event = _message_event(adapter)
await adapter.on_processing_start(event)
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
assert calls == []
@pytest.mark.asyncio
async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_REACTIONS", "true")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.on_processing_start(_message_event(adapter))
assert len(calls) == 1
path, body = calls[0]
assert path == "/react"
assert body["emoji"] == _EYES
assert body["messageId"] == "target-msg-1"
@pytest.mark.asyncio
async def test_processing_success_swaps_to_thumbs_up(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PHOTON_REACTIONS", "true")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.on_processing_complete(
_message_event(adapter), ProcessingOutcome.SUCCESS
)
assert [path for path, _ in calls] == ["/unreact", "/react"]
assert calls[1][1]["emoji"] == _THUMBS_UP
@pytest.mark.asyncio
async def test_processing_failure_swaps_to_thumbs_down(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PHOTON_REACTIONS", "true")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.on_processing_complete(
_message_event(adapter), ProcessingOutcome.FAILURE
)
assert [path for path, _ in calls] == ["/unreact", "/react"]
assert calls[1][1]["emoji"] == _THUMBS_DOWN
@pytest.mark.asyncio
async def test_processing_cancelled_only_removes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PHOTON_REACTIONS", "true")
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.on_processing_complete(
_message_event(adapter), ProcessingOutcome.CANCELLED
)
assert [path for path, _ in calls] == ["/unreact"]
# -- Inbound reaction routing ------------------------------------------------
@pytest.mark.asyncio
async def test_inbound_reaction_on_bot_message_routed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reaction_event(emoji="❤️"))
assert len(captured) == 1
event = captured[0]
assert event.text == "reaction:added:❤️"
assert event.message_type == MessageType.TEXT
assert event.source.chat_id == "+15551234567"
# The tapback correlates to the bot message it reacted to, so the gateway
# can inject `[Replying to your previous message: "..."]` for context.
assert event.reply_to_message_id == "bot-msg-1"
assert event.reply_to_text == "the bot's earlier reply"
assert event.reply_to_is_own_message is True
@pytest.mark.asyncio
async def test_inbound_reaction_without_target_text_correlates_id_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A tapback on an attachment-only bot message (no text) still correlates the
id, but leaves reply_to_text unset — the gateway then skips the reply pointer
(it injects only when both id and text are present)."""
adapter = _make_adapter(monkeypatch)
captured = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reaction_event(target_text=None))
assert len(captured) == 1
event = captured[0]
assert event.reply_to_message_id == "bot-msg-1"
assert event.reply_to_text is None
assert event.reply_to_is_own_message is True
@pytest.mark.asyncio
async def test_inbound_reaction_sent_ids_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No targetDirection from the provider — gate on our own sent-id cache."""
adapter = _make_adapter(monkeypatch)
captured = _capture_handled(adapter, monkeypatch)
adapter._record_sent_message("bot-msg-1")
await adapter._dispatch_inbound(
_reaction_event(target_id="bot-msg-1", target_direction=None)
)
assert len(captured) == 1
@pytest.mark.asyncio
async def test_inbound_reaction_on_foreign_message_dropped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(
_reaction_event(target_id="someone-elses-msg", target_direction=None)
)
assert captured == []
@pytest.mark.asyncio
async def test_inbound_reaction_bypasses_require_mention(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A tapback never carries a wake word — it must skip group gating."""
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
adapter = _make_adapter(monkeypatch)
captured = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reaction_event(space_type="group"))
assert len(captured) == 1
@@ -0,0 +1,110 @@
"""Tests for `hermes photon setup`'s access auto-configuration.
`_autoconfigure_access` allowlists the operator and points the cron home
channel at their DM, writing to the per-test ~/.hermes/.env (the hermetic
HERMES_HOME fixture isolates this). It must fill only unset keys so a re-run
never clobbers a hand-tuned allowlist.
"""
from __future__ import annotations
import argparse
import pytest
from hermes_cli.config import get_env_value, save_env_value
from plugins.platforms.photon.adapter import _env_enablement
from plugins.platforms.photon import cli
def test_autoconfigure_access_fills_unset(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
cli._autoconfigure_access("+15551234567")
assert get_env_value("PHOTON_ALLOWED_USERS") == "+15551234567"
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
def test_autoconfigure_access_preserves_existing_allowlist(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
# A hand-tuned allowlist already in place must survive a setup re-run.
save_env_value("PHOTON_ALLOWED_USERS", "+19998887777,+15551112222")
cli._autoconfigure_access("+15551234567")
assert get_env_value("PHOTON_ALLOWED_USERS") == "+19998887777,+15551112222"
# The still-unset home channel is filled.
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
def test_env_enablement_seeds_home_channel(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
monkeypatch.setenv("PHOTON_HOME_CHANNEL_NAME", "Primary DM")
seed = _env_enablement()
assert seed is not None
assert seed["home_channel"] == {
"chat_id": "+15551234567",
"name": "Primary DM",
}
def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
monkeypatch.delenv("PHOTON_HOME_CHANNEL_NAME", raising=False)
seed = _env_enablement()
assert seed is not None
assert seed["home_channel"] == {
"chat_id": "+15551234567",
"name": "Home",
}
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
# longer enables Spectrum or fetches a separate spectrumProjectId — it
# reuses this id directly.
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
lambda token, dashboard_id: "secret_123",
)
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
monkeypatch.setattr(
cli.photon_auth,
"register_user_if_absent",
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
rc = cli._cmd_setup(
argparse.Namespace(
project_name=None,
phone="+15551234567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
out = capsys.readouterr().out
assert "Start the gateway: hermes gateway start" in out
assert "--platform photon" not in out
@@ -0,0 +1,51 @@
"""Regression tests for the Photon sidecar stale-dependency self-heal.
A `hermes update` that bumps the spectrum-ts pin rewrites the sidecar's
``package-lock.json`` but never reinstalls ``node_modules``, so the sidecar
spawns against stale deps and dies on every reconnect. ``_sidecar_deps_stale``
detects that skew (lockfile newer than npm's install marker) so
``_start_sidecar`` can reinstall before spawning.
"""
from __future__ import annotations
import os
from pathlib import Path
import plugins.platforms.photon.adapter as photon_adapter
def _seed(sidecar: Path, *, lock_mtime: float, marker_mtime: float | None) -> None:
"""Create a fake sidecar dir with a lockfile and (optionally) npm's marker."""
(sidecar / "node_modules").mkdir(parents=True)
lock = sidecar / "package-lock.json"
lock.write_text("{}", encoding="utf-8")
os.utime(lock, (lock_mtime, lock_mtime))
if marker_mtime is not None:
marker = sidecar / "node_modules" / ".package-lock.json"
marker.write_text("{}", encoding="utf-8")
os.utime(marker, (marker_mtime, marker_mtime))
def test_stale_when_lockfile_newer_than_marker(tmp_path, monkeypatch) -> None:
"""The update-rewrites-lockfile-but-skips-install case must reinstall."""
sidecar = tmp_path / "sidecar"
_seed(sidecar, lock_mtime=2000.0, marker_mtime=1000.0)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
assert photon_adapter._sidecar_deps_stale() is True
def test_fresh_when_marker_newer_than_lockfile(tmp_path, monkeypatch) -> None:
"""A normal install (marker at/after lockfile) must NOT trigger a reinstall."""
sidecar = tmp_path / "sidecar"
_seed(sidecar, lock_mtime=1000.0, marker_mtime=2000.0)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
assert photon_adapter._sidecar_deps_stale() is False
def test_not_stale_when_marker_missing(tmp_path, monkeypatch) -> None:
"""No marker (first run / unreadable) must fail safe to False, never block start."""
sidecar = tmp_path / "sidecar"
_seed(sidecar, lock_mtime=2000.0, marker_mtime=None)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
assert photon_adapter._sidecar_deps_stale() is False
@@ -0,0 +1,171 @@
"""Sidecar lifecycle tests: orphan reaping and parent-death wiring.
A hard gateway exit used to leave the detached Node sidecar squatting the
loopback port with a token the next gateway run doesn't know — every
replacement spawn then died on EADDRINUSE. These tests cover the startup
reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without
spawning Node or binding ports.
"""
from __future__ import annotations
import subprocess
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
class _ProbeClient:
"""Fake httpx.AsyncClient whose /healthz probe behavior is injectable."""
connects = True
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_ProbeClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, *a: Any, **k: Any) -> Any:
if not self.connects:
raise photon_adapter.httpx.ConnectError("connection refused")
class _Resp:
status_code = 401 # orphan with a different token
return _Resp()
def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]:
kills: List[Tuple[int, int]] = []
def _fake_kill(pid: int, sig: int) -> None:
kills.append((pid, sig))
monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill)
return kills
@pytest.mark.asyncio
async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
class _Refused(_ProbeClient):
connects = False
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused)
kills = _capture_kills(monkeypatch)
await adapter._reap_stale_sidecar()
assert kills == []
@pytest.mark.asyncio
async def test_reap_kills_verified_orphan(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242])
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True)
# Dies promptly on SIGTERM — no escalation expected.
monkeypatch.setattr(adapter, "_pid_alive", lambda pid: False)
kills = _capture_kills(monkeypatch)
await adapter._reap_stale_sidecar()
assert kills == [(4242, photon_adapter.signal.SIGTERM)]
@pytest.mark.asyncio
async def test_reap_escalates_to_sigkill(monkeypatch: pytest.MonkeyPatch) -> None:
adapter = _make_adapter(monkeypatch)
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242])
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True)
monkeypatch.setattr(adapter, "_pid_alive", lambda pid: True) # ignores TERM
# No clock fakery (logging also calls time.time, which makes a fake clock
# fragile) — this test rides out the real 3s SIGTERM grace window.
kills = _capture_kills(monkeypatch)
await adapter._reap_stale_sidecar()
assert (4242, photon_adapter.signal.SIGTERM) in kills
assert (4242, photon_adapter.signal.SIGKILL) in kills
@pytest.mark.asyncio
async def test_reap_raises_for_foreign_listener(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Never signal a process whose command line isn't our sidecar."""
adapter = _make_adapter(monkeypatch)
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [777])
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: False)
kills = _capture_kills(monkeypatch)
with pytest.raises(RuntimeError, match="in use by another process"):
await adapter._reap_stale_sidecar()
assert kills == []
@pytest.mark.asyncio
async def test_start_sidecar_spawns_with_stdin_pipe(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""The spawn must hold a stdin pipe and enable the sidecar's EOF watch."""
adapter = _make_adapter(monkeypatch)
async def _no_reap() -> None:
pass
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
(tmp_path / "node_modules").mkdir()
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
spawned: Dict[str, Any] = {}
class _FakeProc:
pid = 999
stdout = None
stdin = None
@staticmethod
def poll() -> None:
return None
def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
spawned["cmd"] = cmd
spawned["kwargs"] = kwargs
return _FakeProc()
monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)
class _HealthyClient(_ProbeClient):
async def post(self, *a: Any, **k: Any) -> Any:
class _Resp:
status_code = 200
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)
await adapter._start_sidecar()
kwargs = spawned["kwargs"]
assert kwargs["stdin"] is subprocess.PIPE
assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
@@ -0,0 +1,255 @@
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
def test_sidecar_applies_spectrum_patch_before_importing_sdk() -> None:
"""Existing installs should self-heal at runtime, not only during npm postinstall."""
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
assert "import { patchSpectrumTs }" in index
assert "patchSpectrumTs();" in index
assert index.index("patchSpectrumTs();") < index.index('await import("spectrum-ts")')
def test_sidecar_healthz_reports_stream_health() -> None:
"""Local process health must include upstream stream health."""
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
assert "function streamHealthSnapshot()" in index
assert 'return ok(res, { stream: streamHealthSnapshot() });' in index
assert "STREAM_INTERRUPTED_DEGRADE_COUNT" in index
assert "process.exit(75);" in index
def test_sidecar_intercepts_both_console_channels() -> None:
"""spectrum-ts routes its stream telemetry through @photon-ai/otel, which
sends severity >= ERROR to console.error and WARN/INFO to console.log.
The two lines the health monitor keys off land on *different* channels:
`log.error("stream persistently failing")` -> console.error, but
`log.warn("stream interrupted; reconnecting")` -> console.log. Patching
only console.error would miss every interrupt burst (the primary silent-
inbound symptom), so both channels must be intercepted.
"""
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
assert "function classifyStreamLog(" in index
assert "console.error = (...args) =>" in index
assert "console.log = (...args) =>" in index
# Both wrappers must feed the shared classifier.
assert index.count("classifyStreamLog(text)") >= 2
def test_sidecar_labels_catchup_internal_errors_as_upstream_photon() -> None:
"""Photon cloud stream failures should not look like local auth problems."""
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
assert "function inboundStreamErrorMessage" in index
assert "EventService/CatchUpEvents" in index
assert "this is upstream of Hermes" in index
assert "PHOTON_ALLOWED_USERS" in index
def _tabify(src: str) -> str:
"""Convert the fixture's two-space indentation to the tab indentation that
spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors
(which match tabs) apply exactly as they do against a real install."""
out = []
for line in src.split("\n"):
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped)
return "\n".join(out)
# A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper:
# the two functions the patch rewrites (`rebuildFromAppleMessage` for
# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of
# the helpers they close over. Mirrors the published shape — tab-indented (via
# `_tabify`), `const ... = async` declarations, single-line builder calls — so
# the anchors exercise the real code path, and exporting the two functions lets
# the test assert runtime behavior rather than only string shape.
_SPECTRUM_IMESSAGE_FIXTURE = """
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
const asText = (text) => ({ type: "text", text });
const asCustom = (message) => ({ type: "custom" });
const asProviderGroup = (items) => ({ type: "group", items });
const messageAttachments = (message) => message.content.attachments ?? [];
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp });
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex };
if (parentId !== void 0) msg.parentId = parentId;
return msg;
};
const cacheMessage = (cache, message) => { cache.set(message.id, message); };
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
const messageGuidStr = message.guid;
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
const attachments = messageAttachments(message);
if (attachments.length === 1) {
const info = attachments[0];
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
}
if (attachments.length > 1) {
const items = [];
for (let i = 0; i < attachments.length; i++) {
const info = attachments[i];
if (!info) continue;
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
}
return {
...base,
id: messageGuidStr,
content: asProviderGroup(items)
};
}
const text = message.content.text;
return {
...base,
id: messageGuidStr,
content: text ? asText(text) : asCustom(message)
};
};
const toInboundMessages = async (client, cache, event, phone) => {
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
const messageGuidStr = event.message.guid;
const attachments = messageAttachments(event.message);
if (attachments.length === 1) {
const info = attachments[0];
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
cacheMessage(cache, msg);
return [msg];
}
if (attachments.length > 1) {
const items = [];
for (let i = 0; i < attachments.length; i++) {
const info = attachments[i];
if (!info) continue;
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
}
const parent = {
...base,
id: messageGuidStr,
content: asProviderGroup(items)
};
cacheMessage(cache, parent);
return [parent];
}
const text = event.message.content.text;
const msg = {
...base,
id: messageGuidStr,
content: text ? asText(text) : asCustom(event.message)
};
cacheMessage(cache, msg);
return [msg];
};
export { rebuildFromAppleMessage, toInboundMessages };
"""
def _write_fixture(tmp_path: Path) -> Path:
dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist"
dist.mkdir(parents=True)
chunk = dist / "index.js"
chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8")
return chunk
def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None:
"""The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk
and rewrite both inbound mappers to thread text through attachment bubbles."""
chunk = _write_fixture(tmp_path)
result = subprocess.run(
["node", str(_PATCHER), str(tmp_path)],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
patched = chunk.read_text(encoding="utf-8")
assert "Preserve mixed text + attachment iMessage payloads" in patched
# Single-attachment bubbles wrap the text + attachment in a group...
assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild
assert "content: asProviderGroup([textMsg, msg])" in patched # inbound
# ...multi-attachment bubbles keep the group and shift attachment indices.
assert "content: asProviderGroup(items)" in patched
assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched
# The text is captured in both mappers before the attachment branches run.
assert "const text2 = message.content.text;" in patched
assert "const text2 = event.message.content.text;" in patched
# Re-running is a no-op (idempotent self-heal on every sidecar start).
again = subprocess.run(
["node", str(_PATCHER), str(tmp_path)],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert again.returncode == 0, again.stderr
assert chunk.read_text(encoding="utf-8") == patched
def test_spectrum_patch_preserves_text_at_runtime(tmp_path: Path) -> None:
"""Execute the patched mappers and assert mixed bubbles become groups whose
first child is the typed text, while text-free bubbles keep their exact
original shape (id/partIndex/parentId) so message identity is unchanged."""
chunk = _write_fixture(tmp_path)
patch = subprocess.run(
["node", str(_PATCHER), str(tmp_path)],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert patch.returncode == 0, patch.stderr
harness = textwrap.dedent(
f"""
import {{ rebuildFromAppleMessage, toInboundMessages }} from {str(chunk)!r};
const assert = (c, m) => {{ if (!c) {{ console.error("FAIL: " + m); process.exit(1); }} }};
// Mixed text + single attachment -> group [text@0, attachment@1].
let r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "hello", attachments: [{{ guid: "A0" }}] }} }}, "+1");
assert(r.content.type === "group" && r.id === "G", "single+text -> group parent id=guid");
assert(r.content.items.length === 2, "two items");
assert(r.content.items[0].content.type === "text" && r.content.items[0].content.text === "hello" && r.content.items[0].partIndex === 0 && r.content.items[0].id === "p:0/G", "text child @0");
assert(r.content.items[1].content.type === "attachment" && r.content.items[1].partIndex === 1 && r.content.items[1].id === "p:1/G" && r.content.items[1].parentId === "G", "attachment child @1");
// Single attachment, no text -> unchanged bare attachment.
r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "", attachments: [{{ guid: "A0" }}] }} }}, "+1");
assert(r.content.type === "attachment" && r.id === "G" && r.partIndex === 0 && r.parentId === undefined, "no-text single attachment unchanged");
// Multi attachment + text via the live stream -> group [text@0, att@1, att@2].
let arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G2", content: {{ text: "cap", attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
assert(arr.length === 1 && arr[0].content.type === "group", "multi+text -> single group");
let items = arr[0].content.items;
assert(items.length === 3 && items[0].content.type === "text" && items[0].partIndex === 0, "text first @0");
assert(items[1].partIndex === 1 && items[1].id === "p:1/G2" && items[2].partIndex === 2 && items[2].id === "p:2/G2", "attachments shifted to @1,@2");
// Multi attachment, no text -> unchanged (attachments at @0,@1).
arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G3", content: {{ attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
items = arr[0].content.items;
assert(items.length === 2 && items[0].partIndex === 0 && items[0].id === "p:0/G3" && items[1].partIndex === 1, "no-text multi unchanged");
// Text only, no attachments -> plain text (unchanged).
r = await rebuildFromAppleMessage(null, {{ guid: "G4", content: {{ text: "just text", attachments: [] }} }}, "+1");
assert(r.content.type === "text" && r.content.text === "just text" && r.id === "G4", "text-only unchanged");
"""
)
run = subprocess.run(
["node", "--input-type=module", "-e", harness],
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert run.returncode == 0, run.stderr
+378
View File
@@ -0,0 +1,378 @@
"""Tests for the bundled hermes-achievements dashboard plugin.
These target the two behaviors that matter for official integration:
* The 200-session scan cap is removed — the plugin now walks the entire
session history by default. Lifetime badges (tens of thousands of
tool calls) were unreachable before this fix on long-running installs.
* First-ever scans run in a background thread so the dashboard request
path never blocks, even on 8000+ session databases where a cold scan
takes minutes.
The upstream repo ships its own unittest suite under
``plugins/hermes-achievements/tests/`` covering the achievement engine
internals (tier math, secret-state handling, catalog invariants). These
tests live at the hermes-agent level and focus on the integration
contract: the plugin scans ALL of your sessions, not the first 200.
"""
from __future__ import annotations
import importlib.util
import sys
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
PLUGIN_MODULE_PATH = (
Path(__file__).resolve().parents[2]
/ "plugins"
/ "hermes-achievements"
/ "dashboard"
/ "plugin_api.py"
)
@pytest.fixture
def plugin_api(tmp_path, monkeypatch):
"""Load plugin_api with isolated ~/.hermes so state/snapshot files don't collide.
We load the module fresh per test because the plugin keeps module-level
caches (``_SNAPSHOT_CACHE``, ``_SCAN_STATUS``, background thread handle).
Reloading gives each test a clean world.
"""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
spec = importlib.util.spec_from_file_location(
f"plugin_api_test_{id(tmp_path)}", PLUGIN_MODULE_PATH
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Stash monkeypatch so ``_install_fake_session_db`` can use it to
# swap ``sys.modules['hermes_state']`` with auto-restoration. Without
# this, a raw ``sys.modules[...] = fake`` assignment would leak the
# fake into later tests in the same xdist worker — breaking every
# test that does ``from hermes_state import SessionDB``.
module._test_monkeypatch = monkeypatch
yield module
class _FakeSessionDB:
"""Stand-in for hermes_state.SessionDB that records scan calls."""
def __init__(self, session_count: int, scan_delay: float = 0):
self.session_count = session_count
self.scan_delay = scan_delay
self.last_limit: Optional[int] = None
self.last_include_children: Optional[bool] = None
self.list_calls = 0
self.messages_calls = 0
def list_sessions_rich(
self,
source: Optional[str] = None,
exclude_sources: Optional[List[str]] = None,
limit: int = 20,
offset: int = 0,
include_children: bool = False,
project_compression_tips: bool = True,
) -> List[Dict[str, Any]]:
if self.scan_delay:
time.sleep(self.scan_delay)
self.last_limit = limit
self.last_include_children = include_children
self.list_calls += 1
# SQLite semantics: LIMIT -1 = unlimited. Honor that here.
effective = self.session_count if limit == -1 else min(self.session_count, limit)
now = int(time.time())
return [
{
"id": f"sess-{i}",
"title": f"Session {i}",
"preview": f"preview {i}",
"started_at": now - (self.session_count - i) * 60,
"last_active": now - (self.session_count - i) * 60 + 30,
"source": "cli",
"model": "test-model",
}
for i in range(effective)
]
def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
self.messages_calls += 1
return [
{"role": "user", "content": f"ask {session_id}"},
{
"role": "assistant",
"tool_calls": [{"function": {"name": "terminal"}}],
},
{"role": "tool", "tool_name": "terminal", "content": "ok"},
]
def close(self) -> None:
pass
def _install_fake_session_db(plugin_api, fake_db):
"""Inject a fake SessionDB so ``scan_sessions`` finds it via its local import.
Uses the monkeypatch stashed on ``plugin_api`` by the fixture, so the
``sys.modules['hermes_state']`` swap is auto-restored at test teardown
and cannot leak into unrelated tests in the same xdist worker.
"""
fake_module = type(sys)("hermes_state")
fake_module.SessionDB = lambda: fake_db
plugin_api._test_monkeypatch.setitem(sys.modules, "hermes_state", fake_module)
def test_scan_sessions_default_scans_all_history_not_first_200(plugin_api):
"""Bug regression: ``scan_sessions()`` used to cap at limit=200.
A user with 8000+ sessions would only see ~2% of their history in
achievement totals, making lifetime badges unreachable. The default
now passes ``LIMIT -1`` (SQLite "unlimited") to ``list_sessions_rich``.
"""
fake_db = _FakeSessionDB(session_count=500) # > old 200 cap
_install_fake_session_db(plugin_api, fake_db)
result = plugin_api.scan_sessions()
assert fake_db.last_limit == -1, (
"scan_sessions() must pass LIMIT=-1 (unlimited) to list_sessions_rich "
f"by default, got {fake_db.last_limit}"
)
assert fake_db.last_include_children is True, (
"scan_sessions() must include subagent/compression child sessions so "
"tool calls made in delegated agents still count toward achievements"
)
assert len(result["sessions"]) == 500
assert result["scan_meta"]["sessions_total"] == 500
def test_scan_sessions_explicit_positive_limit_is_honored(plugin_api):
"""Callers can still pass a small limit for smoke tests."""
fake_db = _FakeSessionDB(session_count=500)
_install_fake_session_db(plugin_api, fake_db)
result = plugin_api.scan_sessions(limit=10)
assert fake_db.last_limit == 10
assert len(result["sessions"]) == 10
def test_scan_sessions_zero_or_negative_limit_means_unlimited(plugin_api):
"""``limit=0`` and ``limit=-1`` both map to the unlimited path."""
fake_db = _FakeSessionDB(session_count=300)
_install_fake_session_db(plugin_api, fake_db)
plugin_api.scan_sessions(limit=0)
assert fake_db.last_limit == -1
plugin_api.scan_sessions(limit=-1)
assert fake_db.last_limit == -1
def test_evaluate_all_first_run_returns_pending_and_starts_background_scan(plugin_api):
"""First-ever evaluate_all with no cache returns a pending placeholder
immediately and kicks off a background scan thread. Cold scans on
large DBs take minutes — blocking the dashboard request path is not
acceptable.
"""
fake_db = _FakeSessionDB(session_count=50)
_install_fake_session_db(plugin_api, fake_db)
# Wrap _run_scan_and_update_cache so we can release it on demand,
# simulating a slow cold scan without actually waiting.
scan_started = threading.Event()
allow_scan_finish = threading.Event()
original_run = plugin_api._run_scan_and_update_cache
def gated_run(*args, **kwargs):
scan_started.set()
allow_scan_finish.wait(timeout=5)
original_run(*args, **kwargs)
plugin_api._run_scan_and_update_cache = gated_run
t0 = time.time()
result = plugin_api.evaluate_all()
elapsed = time.time() - t0
# Immediate return — should not block waiting for the scan.
assert elapsed < 1.0, f"evaluate_all blocked for {elapsed:.2f}s on first run"
assert result["scan_meta"]["mode"] == "pending"
assert result["unlocked_count"] == 0
# Catalog still rendered so UI has something to draw.
assert result["total_count"] >= 60
# Background scan is running.
assert scan_started.wait(timeout=2), "background scan did not start"
# Let the scan complete, then a second call returns real data.
allow_scan_finish.set()
# Wait for thread to finish.
thread = plugin_api._BACKGROUND_SCAN_THREAD
assert thread is not None
thread.join(timeout=5)
assert not thread.is_alive()
second = plugin_api.evaluate_all()
assert second["scan_meta"]["mode"] != "pending"
assert second["scan_meta"].get("sessions_total") == 50
def test_evaluate_all_stale_cache_serves_stale_and_refreshes_in_background(plugin_api):
"""When the snapshot is on-disk but older than TTL, evaluate_all returns
the stale data immediately and kicks a background refresh. Users don't
stare at a loading spinner every time TTL expires.
"""
fake_db = _FakeSessionDB(session_count=10, scan_delay=2.0)
_install_fake_session_db(plugin_api, fake_db)
stale_generated_at = int(time.time()) - plugin_api.SNAPSHOT_TTL_SECONDS - 60
stale_payload = {
"achievements": [],
"sessions": [],
"aggregate": {},
"scan_meta": {"mode": "full", "sessions_total": 1, "sessions_rescanned": 1, "sessions_reused": 0},
"error": None,
"unlocked_count": 0,
"discovered_count": 0,
"secret_count": 0,
"total_count": 0,
"generated_at": stale_generated_at,
}
plugin_api.save_snapshot(stale_payload)
t0 = time.time()
result = plugin_api.evaluate_all()
elapsed = time.time() - t0
assert elapsed < 1.0, f"evaluate_all blocked for {elapsed:.2f}s serving stale data"
assert result["generated_at"] == stale_generated_at
# Background scan should be running or have completed.
thread = plugin_api._BACKGROUND_SCAN_THREAD
assert thread is not None
thread.join(timeout=5)
fresh = plugin_api.evaluate_all()
assert fresh["generated_at"] >= stale_generated_at
def test_evaluate_all_force_runs_synchronously(plugin_api):
"""Manual /rescan (force=True) blocks the caller — users clicking
the rescan button expect up-to-date data when the call returns.
"""
fake_db = _FakeSessionDB(session_count=25)
_install_fake_session_db(plugin_api, fake_db)
result = plugin_api.evaluate_all(force=True)
# Synchronous — snapshot is fresh on return.
assert result["scan_meta"].get("sessions_total") == 25
assert result["scan_meta"]["mode"] in {"full", "incremental"}
def test_start_background_scan_is_idempotent_while_running(plugin_api):
"""Multiple concurrent dashboard requests must not spawn duplicate scans."""
fake_db = _FakeSessionDB(session_count=5)
_install_fake_session_db(plugin_api, fake_db)
release = threading.Event()
original_run = plugin_api._run_scan_and_update_cache
def gated_run(*args, **kwargs):
release.wait(timeout=5)
original_run(*args, **kwargs)
plugin_api._run_scan_and_update_cache = gated_run
plugin_api._start_background_scan()
first_thread = plugin_api._BACKGROUND_SCAN_THREAD
assert first_thread is not None and first_thread.is_alive()
plugin_api._start_background_scan()
plugin_api._start_background_scan()
assert plugin_api._BACKGROUND_SCAN_THREAD is first_thread
release.set()
first_thread.join(timeout=5)
def test_background_scan_publishes_partial_snapshots(plugin_api):
"""The background scanner publishes intermediate snapshots to the cache
every ~N sessions. Each dashboard refresh during a long cold scan sees
more badges unlocked instead of staring at zeros for minutes and then
having everything pop at the end.
"""
fake_db = _FakeSessionDB(session_count=750)
_install_fake_session_db(plugin_api, fake_db)
# Record every partial snapshot the scanner publishes.
partial_snapshots: List[Dict[str, Any]] = []
original_compute_from_scan = plugin_api._compute_from_scan
def recording_compute(scan, *, is_partial=False):
result = original_compute_from_scan(scan, is_partial=is_partial)
if is_partial:
partial_snapshots.append(result)
return result
plugin_api._compute_from_scan = recording_compute
# scan 750 sessions with progress_every=250 → expect 2 intermediate
# publications (at 250 and 500; the final 750 call goes through the
# finished, non-partial path).
plugin_api._run_scan_and_update_cache(publish_partial_snapshots=True)
assert len(partial_snapshots) >= 2, (
f"expected at least 2 partial publications on a 750-session scan with "
f"progress_every=250, got {len(partial_snapshots)}"
)
# Partial snapshots should report growing session counts.
counts = [p["scan_meta"].get("sessions_scanned_so_far") for p in partial_snapshots]
assert counts == sorted(counts), f"partial session counts not monotonic: {counts}"
assert counts[0] < 750 and counts[-1] < 750, (
f"partial counts should be less than the final total; got {counts}"
)
# Every partial reports the expected end-state total so the UI can
# show an accurate progress bar.
for p in partial_snapshots:
assert p["scan_meta"].get("sessions_expected_total") == 750
# Final snapshot in cache is the real (non-partial) one.
final = plugin_api._SNAPSHOT_CACHE
assert final is not None
assert final["scan_meta"].get("mode") != "in_progress"
assert final["scan_meta"].get("sessions_total") == 750
def test_partial_snapshots_do_not_persist_unlock_timestamps(plugin_api):
"""Intermediate snapshots must not write to state.json — an unlock
that appears at 30% scan progress could disappear when a later session
rebalances the aggregate. Only the final snapshot records ``unlocked_at``.
"""
fake_db = _FakeSessionDB(session_count=10)
_install_fake_session_db(plugin_api, fake_db)
# Seed empty state, then invoke partial compute directly.
plugin_api.save_state({"unlocks": {}})
partial_scan = {
"sessions": [{"session_id": "x", "tool_call_count": 99999, "tool_names": set()}],
"aggregate": {"max_tool_calls_in_session": 99999, "total_tool_calls": 99999},
"scan_meta": {"mode": "in_progress"},
}
result = plugin_api._compute_from_scan(partial_scan, is_partial=True)
# Some achievements should evaluate as unlocked in this aggregate...
assert any(a["unlocked"] for a in result["achievements"])
# ...but state.json on disk stays empty (no timestamps were recorded).
persisted = plugin_api.load_state()
assert persisted.get("unlocks", {}) == {}, (
"partial scans must not record unlock timestamps — a later session "
"could change whether the badge deserves to be unlocked yet"
)
+203
View File
@@ -0,0 +1,203 @@
"""Unit tests for the Chronos NAS-mediated cron provider (Phase 4D).
All NAS calls are mocked — ZERO live network. These prove:
- is_available is config-only (no network), false without config.
- one-shot arming sends the right provision payload (incl. sub-minute fires —
the agent owns the time, so there's no 1-minute floor).
- reconcile arms missing, cancels orphaned, skips paused.
- fire_due re-arms the next one-shot after a successful run, and repeat-N
(job gone) stops re-arming.
"""
import pytest
@pytest.fixture
def temp_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
yield tmp_path
@pytest.fixture
def chronos(monkeypatch):
"""A ChronosCronScheduler with a fake NAS client capturing calls."""
from plugins.cron_providers.chronos import ChronosCronScheduler
class FakeClient:
def __init__(self):
self.provisions = []
self.cancels = []
self._armed = []
def provision(self, *, job_id, fire_at, agent_callback_url, dedup_key):
self.provisions.append({
"job_id": job_id, "fire_at": fire_at,
"agent_callback_url": agent_callback_url, "dedup_key": dedup_key,
})
return {"schedule_id": f"sched-{job_id}"}
def cancel(self, *, job_id):
self.cancels.append(job_id)
return {}
def list_armed(self):
return list(self._armed)
prov = ChronosCronScheduler()
fake = FakeClient()
prov._client = fake
# callback_url is read via _cfg; patch the module helper to avoid config.
monkeypatch.setattr("plugins.cron_providers.chronos._cfg",
lambda *k, default="": "https://agent.example/" if k[-1] == "callback_url" else "https://portal.test")
return prov, fake
# -- is_available -------------------------------------------------------------
def test_is_available_false_without_config(temp_home, monkeypatch):
from plugins.cron_providers.chronos import ChronosCronScheduler
monkeypatch.setattr("plugins.cron_providers.chronos._cfg", lambda *k, default="": "")
assert ChronosCronScheduler().is_available() is False
def test_is_available_true_with_config_and_token(temp_home, monkeypatch):
import plugins.cron_providers.chronos as mod
from plugins.cron_providers.chronos import ChronosCronScheduler
monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x" )
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state",
lambda pid: {"access_token": "tok"})
assert ChronosCronScheduler().is_available() is True
def test_is_available_makes_no_network(temp_home, monkeypatch):
"""is_available must not construct the NAS client / hit network."""
import plugins.cron_providers.chronos as mod
from plugins.cron_providers.chronos import ChronosCronScheduler
monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x")
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state",
lambda pid: {"access_token": "tok"})
p = ChronosCronScheduler()
def explode():
raise AssertionError("is_available must not build the NAS client")
monkeypatch.setattr(p, "_get_client", explode)
assert p.is_available() is True # did not call _get_client
# -- arming -------------------------------------------------------------------
def test_arm_one_shot_sends_provision(chronos):
prov, fake = chronos
prov._arm_one_shot({"id": "j1", "next_run_at": "2026-06-18T12:00:00+00:00"})
assert len(fake.provisions) == 1
p = fake.provisions[0]
assert p["job_id"] == "j1"
assert p["fire_at"] == "2026-06-18T12:00:00+00:00"
assert p["dedup_key"] == "j1:2026-06-18T12:00:00+00:00"
assert p["agent_callback_url"] == "https://agent.example/"
def test_arm_one_shot_preserves_sub_minute_fire(chronos):
"""Sub-minute fire times survive — the agent owns the time, so there's no
1-minute scheduler floor."""
prov, fake = chronos
prov._arm_one_shot({"id": "j2", "next_run_at": "2026-06-18T12:00:30+00:00"})
assert fake.provisions[0]["fire_at"] == "2026-06-18T12:00:30+00:00"
def test_arm_one_shot_noop_without_next_run(chronos):
prov, fake = chronos
prov._arm_one_shot({"id": "j3", "next_run_at": None})
assert fake.provisions == []
# -- reconcile ----------------------------------------------------------------
def test_reconcile_arms_all_enabled(temp_home, chronos, monkeypatch):
prov, fake = chronos
jobs = [
{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"},
{"id": "b", "enabled": True, "next_run_at": "2026-06-18T12:05:00+00:00", "state": "scheduled"},
]
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: next(j for j in jobs if j["id"] == jid))
prov.reconcile()
assert {p["job_id"] for p in fake.provisions} == {"a", "b"}
assert fake.cancels == []
def test_reconcile_cancels_orphan_arms_desired(temp_home, chronos, monkeypatch):
prov, fake = chronos
# NAS already has a stale arm for deleted job "gone".
prov._armed = {"gone": "2026-06-18T11:00:00+00:00"}
jobs = [{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}]
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: next((j for j in jobs if j["id"] == jid), None))
prov.reconcile()
assert [p["job_id"] for p in fake.provisions] == ["a"]
assert fake.cancels == ["gone"]
def test_reconcile_skips_paused(temp_home, chronos, monkeypatch):
prov, fake = chronos
jobs = [{"id": "p", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "paused"}]
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: next((j for j in jobs if j["id"] == jid), None))
prov.reconcile()
assert fake.provisions == []
def test_reconcile_skips_already_armed_same_time(temp_home, chronos, monkeypatch):
prov, fake = chronos
prov._armed = {"a": "2026-06-18T12:00:00+00:00"}
jobs = [{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}]
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: jobs[0])
prov.reconcile()
assert fake.provisions == [] # already armed at the same time → no re-arm
# -- fire_due re-arm ----------------------------------------------------------
def test_fire_due_rearms_next_oneshot(chronos, monkeypatch):
prov, fake = chronos
# super().fire_due runs the job; stub the ABC default to "ran".
monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due",
lambda self, jid, **kw: True)
monkeypatch.setattr("cron.jobs.get_job",
lambda jid: {"id": jid, "enabled": True, "next_run_at": "2026-06-18T12:05:00+00:00"})
assert prov.fire_due("j1") is True
assert [p["job_id"] for p in fake.provisions] == ["j1"]
assert fake.provisions[0]["fire_at"] == "2026-06-18T12:05:00+00:00"
def test_fire_due_no_rearm_when_job_gone(chronos, monkeypatch):
"""repeat-N exhausted / one-shot completed → mark_job_run deleted the job →
get_job None → no re-arm (the schedule stops cleanly)."""
prov, fake = chronos
monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due",
lambda self, jid, **kw: True)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: None)
assert prov.fire_due("j1") is True
assert fake.provisions == []
def test_fire_due_no_rearm_when_claim_lost(chronos, monkeypatch):
"""If the run didn't happen (claim lost), don't re-arm."""
prov, fake = chronos
monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due",
lambda self, jid, **kw: False)
assert prov.fire_due("j1") is False
assert fake.provisions == []
+182
View File
@@ -0,0 +1,182 @@
"""Tests for the Chronos inbound cron-fire JWT verifier (Phase 4E.1).
These exercise REAL RS256 signing/verification (PyJWT[crypto] is a declared
dependency) against an inline PEM public key — no mocking of the crypto, since
this is a security boundary. The JWKS-URL path is covered separately by mocking
PyJWKClient's key resolution.
"""
import time
import pytest
@pytest.fixture(scope="module")
def rsa_keys():
"""An RS256 keypair: (private_pem, public_pem)."""
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
pub = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return priv, pub
def _mint(priv, claims):
import jwt
return jwt.encode(claims, priv, algorithm="RS256")
AUD = "agent:inst-123"
ISS = "https://portal.nousresearch.com"
def _base_claims(**over):
now = int(time.time())
c = {
"aud": AUD,
"iss": ISS,
"purpose": "cron_fire",
"iat": now,
"nbf": now - 5,
"exp": now + 300,
}
c.update(over)
return c
def test_valid_token_returns_claims(rsa_keys):
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
token = _mint(priv, _base_claims())
claims = verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS)
assert claims is not None
assert claims["purpose"] == "cron_fire"
assert claims["aud"] == AUD
def test_wrong_audience_rejected(rsa_keys):
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
token = _mint(priv, _base_claims(aud="agent:someone-else"))
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS) is None
def test_missing_purpose_rejected(rsa_keys):
"""A general agent JWT (no purpose=cron_fire) can't fire jobs."""
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
claims = _base_claims()
del claims["purpose"]
token = _mint(priv, claims)
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS) is None
def test_wrong_purpose_rejected(rsa_keys):
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
token = _mint(priv, _base_claims(purpose="inference"))
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS) is None
def test_expired_token_rejected(rsa_keys):
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
now = int(time.time())
token = _mint(priv, _base_claims(iat=now - 1000, nbf=now - 1000, exp=now - 600))
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS) is None
def test_wrong_issuer_rejected(rsa_keys):
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
token = _mint(priv, _base_claims(iss="https://evil.example"))
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS) is None
def test_tampered_signature_rejected(rsa_keys):
"""A token signed by a DIFFERENT key must fail signature verification."""
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
_, pub = rsa_keys
attacker = rsa.generate_private_key(public_exponent=65537, key_size=2048)
attacker_priv = attacker.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
token = _mint(attacker_priv, _base_claims())
# Verified against the REAL public key → signature mismatch → None.
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=pub, issuer=ISS) is None
def test_no_key_configured_refuses(rsa_keys):
"""No JWKS/key configured → refuse (never fall back to unsigned decode)."""
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, _ = rsa_keys
token = _mint(priv, _base_claims())
assert verify_nas_fire_token(token=token, expected_audience=AUD,
jwks_or_key=None) is None
def test_empty_token_refused(rsa_keys):
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
_, pub = rsa_keys
assert verify_nas_fire_token(token="", expected_audience=AUD, jwks_or_key=pub) is None
def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch):
"""The JWKS-URL branch resolves the signing key via PyJWKClient."""
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
priv, pub = rsa_keys
token = _mint(priv, _base_claims())
class FakeKey:
key = pub
class FakeJWKClient:
def __init__(self, url):
assert url == "https://portal.nousresearch.com/.well-known/jwks.json"
def get_signing_key_from_jwt(self, tok):
return FakeKey()
monkeypatch.setattr("jwt.PyJWKClient", FakeJWKClient)
claims = verify_nas_fire_token(
token=token, expected_audience=AUD,
jwks_or_key="https://portal.nousresearch.com/.well-known/jwks.json",
issuer=ISS,
)
assert claims is not None and claims["purpose"] == "cron_fire"
def test_get_fire_verifier_returns_nas_verifier():
from plugins.cron_providers.chronos.verify import get_fire_verifier, verify_nas_fire_token
assert get_fire_verifier() is verify_nas_fire_token
@@ -0,0 +1,59 @@
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.discord.adapter import DiscordAdapter
@pytest.mark.asyncio
async def test_discord_bot_task_runtime_exit_notifies_gateway_for_reconnect(monkeypatch):
"""A post-ready discord.py websocket task crash must not leave the gateway split-brained.
Regression: producers stayed systemd-active while Discord stopped responding after
a runtime ClientOSError/ConnectionResetError. The adapter must mark Discord as a
retryable fatal platform error and notify the gateway supervisor so the existing
reconnect watcher can replace the dead adapter.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
adapter._running = True
adapter._ready_event.set()
adapter._notify_fatal_error = AsyncMock()
async def crash():
raise ConnectionResetError("Cannot write to closing transport")
task = asyncio.create_task(crash())
await asyncio.sleep(0)
adapter._handle_bot_task_done(task)
await asyncio.sleep(0)
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is True
assert adapter.fatal_error_code == "discord_gateway_task_exited"
assert adapter.fatal_error_message is not None
assert "Cannot write to closing transport" in adapter.fatal_error_message
adapter._notify_fatal_error.assert_awaited_once()
@pytest.mark.asyncio
async def test_discord_bot_task_done_ignored_during_intentional_disconnect():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
adapter._running = True
adapter._ready_event.set()
adapter._disconnecting = True
adapter._notify_fatal_error = AsyncMock()
async def stop_cleanly():
return None
task = asyncio.create_task(stop_cleanly())
await asyncio.sleep(0)
adapter._handle_bot_task_done(task)
await asyncio.sleep(0)
assert adapter.has_fatal_error is False
adapter._notify_fatal_error.assert_not_awaited()
+645
View File
@@ -0,0 +1,645 @@
"""Tests for the disk-cleanup plugin.
Covers the bundled plugin at ``plugins/disk-cleanup/``:
* ``disk_cleanup`` library: track / forget / dry_run / quick / status,
``is_safe_path`` and ``guess_category`` filtering.
* Plugin ``__init__``: ``post_tool_call`` hook auto-tracks files created
by ``write_file`` / ``terminal``; ``on_session_end`` hook runs quick
cleanup when anything was tracked during the turn.
* Slash command handler: status / dry-run / quick / track / forget /
unknown subcommand behaviours.
* Bundled-plugin discovery via ``PluginManager.discover_and_load``.
"""
import importlib
import json
import sys
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _isolate_env(tmp_path, monkeypatch):
"""Isolate HERMES_HOME for each test.
The global hermetic fixture already redirects HERMES_HOME to a tempdir,
but we want the plugin to work with a predictable subpath. We reset
HERMES_HOME here for clarity.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
yield hermes_home
def _load_lib():
"""Import the plugin's library module directly from the repo path."""
repo_root = Path(__file__).resolve().parents[2]
lib_path = repo_root / "plugins" / "disk-cleanup" / "disk_cleanup.py"
spec = importlib.util.spec_from_file_location(
"disk_cleanup_under_test", lib_path
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _load_plugin_init():
"""Import the plugin's __init__.py (which depends on the library)."""
repo_root = Path(__file__).resolve().parents[2]
plugin_dir = repo_root / "plugins" / "disk-cleanup"
# Use the PluginManager's module naming convention so relative imports work.
spec = importlib.util.spec_from_file_location(
"hermes_plugins.disk_cleanup",
plugin_dir / "__init__.py",
submodule_search_locations=[str(plugin_dir)],
)
# Ensure parent namespace package exists for the relative `. import disk_cleanup`
import types
if "hermes_plugins" not in sys.modules:
ns = types.ModuleType("hermes_plugins")
ns.__path__ = []
sys.modules["hermes_plugins"] = ns
mod = importlib.util.module_from_spec(spec)
mod.__package__ = "hermes_plugins.disk_cleanup"
mod.__path__ = [str(plugin_dir)]
sys.modules["hermes_plugins.disk_cleanup"] = mod
spec.loader.exec_module(mod)
return mod
# ---------------------------------------------------------------------------
# Library tests
# ---------------------------------------------------------------------------
class TestIsSafePath:
def test_accepts_path_under_hermes_home(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "subdir" / "file.txt"
p.parent.mkdir()
p.write_text("x")
assert dg.is_safe_path(p) is True
def test_rejects_outside_hermes_home(self, _isolate_env):
dg = _load_lib()
assert dg.is_safe_path(Path("/etc/passwd")) is False
def test_accepts_tmp_hermes_prefix(self, _isolate_env, tmp_path):
dg = _load_lib()
assert dg.is_safe_path(Path("/tmp/hermes-abc/x.log")) is True
def test_rejects_plain_tmp(self, _isolate_env):
dg = _load_lib()
assert dg.is_safe_path(Path("/tmp/other.log")) is False
def test_rejects_windows_mount(self, _isolate_env):
dg = _load_lib()
assert dg.is_safe_path(Path("/mnt/c/Users/x/test.txt")) is False
class TestGuessCategory:
def test_test_prefix(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "test_foo.py"
p.write_text("x")
assert dg.guess_category(p) == "test"
def test_tmp_prefix(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "tmp_foo.log"
p.write_text("x")
assert dg.guess_category(p) == "test"
def test_dot_test_suffix(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "mything.test.js"
p.write_text("x")
assert dg.guess_category(p) == "test"
def test_skips_protected_top_level(self, _isolate_env):
dg = _load_lib()
logs_dir = _isolate_env / "logs"
logs_dir.mkdir()
p = logs_dir / "test_log.txt"
p.write_text("x")
# Even though it matches test_* pattern, logs/ is excluded.
assert dg.guess_category(p) is None
def test_cron_subtree_categorised(self, _isolate_env):
dg = _load_lib()
# Only files under ``cron/output/`` are disposable run artifacts.
output_dir = _isolate_env / "cron" / "output" / "job_123"
output_dir.mkdir(parents=True)
p = output_dir / "run.md"
p.write_text("x")
assert dg.guess_category(p) == "cron-output"
def test_cron_output_root_not_tracked(self, _isolate_env):
"""The cron/output root is durable container state, not an artifact."""
dg = _load_lib()
output_root = _isolate_env / "cron" / "output"
output_root.mkdir(parents=True)
assert dg.guess_category(output_root) is None
def test_cron_jobs_json_not_tracked(self, _isolate_env):
"""Regression for #32164: the cron registry must never be tracked."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
p = cron_dir / "jobs.json"
p.write_text("[]")
assert dg.guess_category(p) is None
def test_cron_tick_lock_not_tracked(self, _isolate_env):
"""Regression for #32164: cron tick-lock is control-plane state."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
p = cron_dir / ".tick.lock"
p.write_text("")
assert dg.guess_category(p) is None
def test_cronjobs_top_level_not_tracked(self, _isolate_env):
"""The legacy ``cronjobs`` alias is also control-plane at the top."""
dg = _load_lib()
cron_dir = _isolate_env / "cronjobs"
cron_dir.mkdir()
p = cron_dir / "jobs.json"
p.write_text("[]")
assert dg.guess_category(p) is None
def test_ordinary_file_returns_none(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "notes.md"
p.write_text("x")
assert dg.guess_category(p) is None
class TestStaleCronEntryMigration:
"""Regression tests for #37721 — stale cron-output entries in tracked.json."""
def test_quick_skips_stale_cron_output_for_jobs_json(self, _isolate_env):
"""A stale tracked.json entry with category="cron-output" for
cron/jobs.json must NOT be deleted by quick().
This is the exact scenario from #37721: an old tracked.json has
{"path": ".../cron/jobs.json", "category": "cron-output"} which
would pass the delete filter but must be skipped because
guess_category() now returns None for non-output cron paths.
"""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
jobs_json = cron_dir / "jobs.json"
jobs_json.write_text('{"jobs": []}')
# Simulate a stale tracked.json entry from before #34840 by
# directly writing the tracked file (track() would reject it).
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(jobs_json),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00", # very old
"size": 123,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, "cron/jobs.json must not be deleted"
assert jobs_json.exists(), "jobs.json must still exist"
# The stale entry should have been dropped from tracking.
remaining = json.loads(tracked_file.read_text())
assert len(remaining) == 0
def test_quick_skips_stale_cron_output_for_cron_dir(self, _isolate_env):
"""Stale entry for the cron/ directory itself must not be deleted."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
output_dir = cron_dir / "output"
output_dir.mkdir()
(output_dir / "run.md").write_text("x")
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(cron_dir),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 0,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, "cron/ dir must not be deleted"
assert cron_dir.exists()
def test_quick_skips_stale_cron_output_for_output_root(self, _isolate_env):
"""Stale entry for cron/output itself must not delete all job output."""
dg = _load_lib()
output_root = _isolate_env / "cron" / "output"
job_dir = output_root / "job_1"
job_dir.mkdir(parents=True)
run_md = job_dir / "run.md"
run_md.write_text("x")
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(output_root),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 0,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, "cron/output root must not be deleted"
assert output_root.exists()
assert run_md.exists()
def test_quick_skips_protected_cron_paths_defense_in_depth(self, _isolate_env):
"""Defense-in-depth: even if guess_category returned cron-output
(hypothetically), protected cron paths are never deleted."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
tick_lock = cron_dir / ".tick.lock"
tick_lock.write_text("")
# Manually inject a stale entry with "test" category (would normally
# be auto-deleted) — the protected path guard must still block it.
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(tick_lock),
"category": "test",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 0,
}]))
summary = dg.quick()
assert summary["deleted"] == 0, ".tick.lock must not be deleted"
assert tick_lock.exists()
def test_dry_run_omits_stale_cron_output(self, _isolate_env):
"""dry_run() should also skip stale cron-output entries."""
dg = _load_lib()
cron_dir = _isolate_env / "cron"
cron_dir.mkdir()
jobs_json = cron_dir / "jobs.json"
jobs_json.write_text("[]")
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(jobs_json),
"category": "cron-output",
"timestamp": "2025-01-01T00:00:00+00:00",
"size": 123,
}]))
auto, prompt = dg.dry_run()
assert len(auto) == 0, "stale cron-output for jobs.json must not appear"
assert len(prompt) == 0
def test_legitimate_cron_output_still_deleted(self, _isolate_env):
"""A valid cron-output entry under cron/output/ must still be deleted."""
dg = _load_lib()
output_dir = _isolate_env / "cron" / "output" / "job_1"
output_dir.mkdir(parents=True)
run_md = output_dir / "run.md"
run_md.write_text("x")
# Old enough to be deleted (>14 days)
from datetime import datetime, timezone, timedelta
old_ts = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat()
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
tracked_file.parent.mkdir(parents=True, exist_ok=True)
tracked_file.write_text(json.dumps([{
"path": str(run_md),
"category": "cron-output",
"timestamp": old_ts,
"size": 10,
}]))
summary = dg.quick()
assert summary["deleted"] == 1, "valid old cron-output should be deleted"
assert not run_md.exists()
class TestTrackForgetQuick:
def test_track_then_quick_deletes_test(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "test_a.py"
p.write_text("x")
assert dg.track(str(p), "test", silent=True) is True
summary = dg.quick()
assert summary["deleted"] == 1
assert not p.exists()
def test_track_dedup(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "test_a.py"
p.write_text("x")
assert dg.track(str(p), "test", silent=True) is True
# Second call returns False (already tracked)
assert dg.track(str(p), "test", silent=True) is False
def test_track_rejects_outside_home(self, _isolate_env):
dg = _load_lib()
# /etc/hostname exists on most Linux boxes; fall back if not.
outside = "/etc/hostname" if Path("/etc/hostname").exists() else "/etc/passwd"
assert dg.track(outside, "test", silent=True) is False
def test_track_skips_missing(self, _isolate_env):
dg = _load_lib()
assert dg.track(str(_isolate_env / "nope.txt"), "test", silent=True) is False
def test_forget_removes_entry(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "keep.tmp"
p.write_text("x")
dg.track(str(p), "temp", silent=True)
assert dg.forget(str(p)) == 1
assert p.exists() # forget does NOT delete the file
def test_quick_preserves_unexpired_temp(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "fresh.tmp"
p.write_text("x")
dg.track(str(p), "temp", silent=True)
summary = dg.quick()
assert summary["deleted"] == 0
assert p.exists()
def test_quick_preserves_protected_top_level_dirs(self, _isolate_env):
dg = _load_lib()
for d in ("logs", "memories", "sessions", "cron", "cache"):
(_isolate_env / d).mkdir()
dg.quick()
for d in ("logs", "memories", "sessions", "cron", "cache"):
assert (_isolate_env / d).exists(), f"{d}/ should be preserved"
def test_quick_does_not_descend_into_protected_top_level_dirs(
self, _isolate_env, monkeypatch
):
dg = _load_lib()
protected_empty = (
_isolate_env / "hermes-agent" / "node_modules" / "pkg" / "empty"
)
protected_empty.mkdir(parents=True)
original_iterdir = Path.iterdir
def guarded_iterdir(path):
if path == _isolate_env / "hermes-agent":
raise AssertionError("quick() descended into protected hermes-agent/")
return original_iterdir(path)
monkeypatch.setattr(Path, "iterdir", guarded_iterdir)
dg.quick()
assert protected_empty.exists()
def test_quick_removes_empty_dirs_in_managed_subtrees(self, _isolate_env):
dg = _load_lib()
managed_empty = _isolate_env / "scratch" / "nested" / "empty"
managed_empty.mkdir(parents=True)
dg.quick()
assert not (_isolate_env / "scratch").exists()
class TestStatus:
def test_empty_status(self, _isolate_env):
dg = _load_lib()
s = dg.status()
assert s["total_tracked"] == 0
assert s["top10"] == []
def test_status_with_entries(self, _isolate_env):
dg = _load_lib()
p = _isolate_env / "big.tmp"
p.write_text("y" * 100)
dg.track(str(p), "temp", silent=True)
s = dg.status()
assert s["total_tracked"] == 1
assert len(s["top10"]) == 1
rendered = dg.format_status(s)
assert "temp" in rendered
assert "big.tmp" in rendered
class TestDryRun:
def test_classifies_by_category(self, _isolate_env):
dg = _load_lib()
test_f = _isolate_env / "test_x.py"
test_f.write_text("x")
big = _isolate_env / "big.bin"
big.write_bytes(b"z" * 10)
dg.track(str(test_f), "test", silent=True)
dg.track(str(big), "other", silent=True)
auto, prompt = dg.dry_run()
# test → auto, other → neither (doesn't hit any rule)
assert any(i["path"] == str(test_f) for i in auto)
# ---------------------------------------------------------------------------
# Plugin hooks tests
# ---------------------------------------------------------------------------
class TestPostToolCallHook:
def test_write_file_test_pattern_tracked(self, _isolate_env):
pi = _load_plugin_init()
p = _isolate_env / "test_created.py"
p.write_text("x")
pi._on_post_tool_call(
tool_name="write_file",
args={"path": str(p), "content": "x"},
result="OK",
task_id="t1", session_id="s1",
)
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
data = json.loads(tracked_file.read_text())
assert len(data) == 1
assert data[0]["category"] == "test"
def test_write_file_non_test_not_tracked(self, _isolate_env):
pi = _load_plugin_init()
p = _isolate_env / "notes.md"
p.write_text("x")
pi._on_post_tool_call(
tool_name="write_file",
args={"path": str(p), "content": "x"},
result="OK",
task_id="t2", session_id="s2",
)
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]"
def test_terminal_command_picks_up_paths(self, _isolate_env):
pi = _load_plugin_init()
p = _isolate_env / "tmp_created.log"
p.write_text("x")
pi._on_post_tool_call(
tool_name="terminal",
args={"command": f"touch {p}"},
result=f"created {p}\n",
task_id="t3", session_id="s3",
)
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
data = json.loads(tracked_file.read_text())
assert any(Path(i["path"]) == p.resolve() for i in data)
def test_ignores_unrelated_tool(self, _isolate_env):
pi = _load_plugin_init()
pi._on_post_tool_call(
tool_name="read_file",
args={"path": str(_isolate_env / "test_x.py")},
result="contents",
task_id="t4", session_id="s4",
)
# read_file should never trigger tracking.
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]"
class TestOnSessionEndHook:
def test_runs_quick_when_test_files_tracked(self, _isolate_env):
pi = _load_plugin_init()
p = _isolate_env / "test_cleanup.py"
p.write_text("x")
pi._on_post_tool_call(
tool_name="write_file",
args={"path": str(p), "content": "x"},
result="OK",
task_id="", session_id="s1",
)
assert p.exists()
pi._on_session_end(session_id="s1", completed=True, interrupted=False)
assert not p.exists(), "test file should be auto-deleted"
def test_noop_when_no_test_tracked(self, _isolate_env):
pi = _load_plugin_init()
# Nothing tracked → on_session_end should not raise.
pi._on_session_end(session_id="empty", completed=True, interrupted=False)
# ---------------------------------------------------------------------------
# Slash command
# ---------------------------------------------------------------------------
class TestSlashCommand:
def test_help(self, _isolate_env):
pi = _load_plugin_init()
out = pi._handle_slash("help")
assert "disk-cleanup" in out
assert "status" in out
def test_status_empty(self, _isolate_env):
pi = _load_plugin_init()
out = pi._handle_slash("status")
assert "nothing tracked" in out
def test_track_rejects_missing(self, _isolate_env):
pi = _load_plugin_init()
out = pi._handle_slash(
f"track {_isolate_env / 'nope.txt'} temp"
)
assert "Not tracked" in out
def test_track_rejects_bad_category(self, _isolate_env):
pi = _load_plugin_init()
p = _isolate_env / "a.tmp"
p.write_text("x")
out = pi._handle_slash(f"track {p} banana")
assert "Unknown category" in out
def test_track_and_forget(self, _isolate_env):
pi = _load_plugin_init()
p = _isolate_env / "a.tmp"
p.write_text("x")
out = pi._handle_slash(f"track {p} temp")
assert "Tracked" in out
out = pi._handle_slash(f"forget {p}")
assert "Removed 1" in out
def test_unknown_subcommand(self, _isolate_env):
pi = _load_plugin_init()
out = pi._handle_slash("foobar")
assert "Unknown subcommand" in out
def test_quick_on_empty(self, _isolate_env):
pi = _load_plugin_init()
out = pi._handle_slash("quick")
assert "Cleaned 0 files" in out
# ---------------------------------------------------------------------------
# Bundled-plugin discovery
# ---------------------------------------------------------------------------
class TestBundledDiscovery:
def _write_enabled_config(self, hermes_home, names):
"""Write plugins.enabled allow-list to config.yaml."""
import yaml
cfg_path = hermes_home / "config.yaml"
cfg_path.write_text(yaml.safe_dump({"plugins": {"enabled": list(names)}}))
def test_disk_cleanup_discovered_but_not_loaded_by_default(self, _isolate_env):
"""Bundled plugins are discovered but NOT loaded without opt-in."""
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
# Discovered — appears in the registry
assert "disk-cleanup" in mgr._plugins
loaded = mgr._plugins["disk-cleanup"]
assert loaded.manifest.source == "bundled"
# But NOT enabled — no hooks or commands registered
assert not loaded.enabled
assert loaded.error and "not enabled" in loaded.error
def test_disk_cleanup_loads_when_enabled(self, _isolate_env):
"""Adding to plugins.enabled activates the bundled plugin."""
self._write_enabled_config(_isolate_env, ["disk-cleanup"])
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["disk-cleanup"]
assert loaded.enabled
assert "post_tool_call" in loaded.hooks_registered
assert "on_session_end" in loaded.hooks_registered
assert "disk-cleanup" in loaded.commands_registered
def test_disabled_beats_enabled(self, _isolate_env):
"""plugins.disabled wins even if the plugin is also in plugins.enabled."""
import yaml
cfg_path = _isolate_env / "config.yaml"
cfg_path.write_text(yaml.safe_dump({
"plugins": {
"enabled": ["disk-cleanup"],
"disabled": ["disk-cleanup"],
}
}))
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["disk-cleanup"]
assert not loaded.enabled
assert loaded.error == "disabled via config"
def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env):
"""Bundled scan must NOT pick up plugins/memory or plugins/context_engine
as top-level plugins — they have their own discovery paths."""
self._write_enabled_config(
_isolate_env, ["memory", "context_engine", "disk-cleanup"]
)
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
assert "memory" not in mgr._plugins
assert "context_engine" not in mgr._plugins
+265
View File
@@ -0,0 +1,265 @@
"""Tests for plugins.google_meet.audio_bridge (v2).
Covers the platform gating and pactl / system_profiler plumbing
without actually invoking those tools on the host.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
yield hermes_home
# ---------------------------------------------------------------------------
# Linux setup / teardown
# ---------------------------------------------------------------------------
def _linux_pactl_result(stdout: str) -> MagicMock:
"""Build a fake CompletedProcess-ish object for subprocess.run."""
m = MagicMock()
m.stdout = stdout
m.stderr = ""
m.returncode = 0
return m
def test_setup_linux_loads_null_sink_and_virtual_source():
from plugins.google_meet.audio_bridge import AudioBridge
calls: list[list[str]] = []
def _fake_run(argv, **kwargs):
calls.append(list(argv))
# First call = null-sink → module id 42
# Second call = virtual-source → module id 43
if "module-null-sink" in argv:
return _linux_pactl_result("42\n")
if "module-virtual-source" in argv:
return _linux_pactl_result("43\n")
raise AssertionError(f"unexpected pactl invocation: {argv}")
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Linux"), \
patch("plugins.google_meet.audio_bridge.subprocess.run",
side_effect=_fake_run):
br = AudioBridge()
info = br.setup()
# Two pactl load-module calls, in order.
assert len(calls) == 2
assert calls[0][0] == "pactl" and calls[0][1] == "load-module"
assert "module-null-sink" in calls[0]
assert any(a.startswith("sink_name=hermes_meet_sink") for a in calls[0])
assert calls[1][0] == "pactl" and calls[1][1] == "load-module"
assert "module-virtual-source" in calls[1]
assert any(a.startswith("source_name=hermes_meet_src") for a in calls[1])
assert any("master=hermes_meet_sink.monitor" in a for a in calls[1])
# Dict shape.
assert info["platform"] == "linux"
assert info["device_name"] == "hermes_meet_src"
assert info["write_target"] == "hermes_meet_sink"
assert info["sample_rate"] == 48000
assert info["channels"] == 2
assert info["module_ids"] == [42, 43]
# Properties.
assert br.device_name == "hermes_meet_src"
assert br.write_target == "hermes_meet_sink"
def test_teardown_linux_unloads_modules_in_reverse_order():
from plugins.google_meet.audio_bridge import AudioBridge
def _setup_run(argv, **kwargs):
if "module-null-sink" in argv:
return _linux_pactl_result("42\n")
return _linux_pactl_result("43\n")
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Linux"), \
patch("plugins.google_meet.audio_bridge.subprocess.run",
side_effect=_setup_run):
br = AudioBridge()
br.setup()
unload_calls: list[list[str]] = []
def _teardown_run(argv, **kwargs):
unload_calls.append(list(argv))
return _linux_pactl_result("")
with patch("plugins.google_meet.audio_bridge.subprocess.run",
side_effect=_teardown_run):
br.teardown()
# Two unload calls, in reverse order: 43 (virtual-source) then 42 (sink).
assert [c[1] for c in unload_calls] == ["unload-module", "unload-module"]
assert unload_calls[0][2] == "43"
assert unload_calls[1][2] == "42"
# Second teardown is a no-op.
with patch("plugins.google_meet.audio_bridge.subprocess.run") as run_mock:
br.teardown()
run_mock.assert_not_called()
def test_setup_linux_parses_module_id_from_multi_line_output():
"""Some pactl builds include trailing whitespace / notices."""
from plugins.google_meet.audio_bridge import AudioBridge
def _fake_run(argv, **kwargs):
if "module-null-sink" in argv:
return _linux_pactl_result("42 \n")
return _linux_pactl_result("43\n")
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Linux"), \
patch("plugins.google_meet.audio_bridge.subprocess.run",
side_effect=_fake_run):
br = AudioBridge()
info = br.setup()
assert info["module_ids"] == [42, 43]
def test_setup_linux_pactl_missing_raises_clean_error():
from plugins.google_meet.audio_bridge import AudioBridge
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Linux"), \
patch("plugins.google_meet.audio_bridge.subprocess.run",
side_effect=FileNotFoundError("pactl")):
br = AudioBridge()
with pytest.raises(RuntimeError, match="pactl"):
br.setup()
# ---------------------------------------------------------------------------
# macOS setup
# ---------------------------------------------------------------------------
_BH_PRESENT = (
"Audio:\n"
" Devices:\n"
" BlackHole 2ch:\n"
" Manufacturer: Existential Audio\n"
)
_BH_ABSENT = (
"Audio:\n"
" Devices:\n"
" MacBook Pro Microphone:\n"
" Default Input: Yes\n"
)
def test_setup_darwin_returns_blackhole_when_present():
from plugins.google_meet.audio_bridge import AudioBridge
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Darwin"), \
patch("plugins.google_meet.audio_bridge.subprocess.check_output",
return_value=_BH_PRESENT) as check:
br = AudioBridge()
info = br.setup()
check.assert_called_once()
argv = check.call_args.args[0]
assert argv[0] == "system_profiler"
assert "SPAudioDataType" in argv
assert info["platform"] == "darwin"
assert info["device_name"] == "BlackHole 2ch"
assert info["write_target"] == "BlackHole 2ch"
assert info["module_ids"] == []
assert info["sample_rate"] == 48000
assert info["channels"] == 2
# teardown is a no-op on darwin (no modules to unload).
with patch("plugins.google_meet.audio_bridge.subprocess.run") as run_mock:
br.teardown()
run_mock.assert_not_called()
def test_setup_darwin_raises_when_blackhole_missing():
from plugins.google_meet.audio_bridge import AudioBridge
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Darwin"), \
patch("plugins.google_meet.audio_bridge.subprocess.check_output",
return_value=_BH_ABSENT):
br = AudioBridge()
with pytest.raises(RuntimeError, match="BlackHole"):
br.setup()
# ---------------------------------------------------------------------------
# Windows / unsupported
# ---------------------------------------------------------------------------
def test_setup_windows_raises():
from plugins.google_meet.audio_bridge import AudioBridge
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Windows"):
br = AudioBridge()
with pytest.raises(RuntimeError, match="not supported"):
br.setup()
# ---------------------------------------------------------------------------
# chrome_fake_audio_flags
# ---------------------------------------------------------------------------
def test_chrome_fake_audio_flags_linux():
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Linux"):
flags = chrome_fake_audio_flags(
{"platform": "linux", "device_name": "hermes_meet_src"}
)
assert "--use-fake-ui-for-media-stream" in flags
def test_chrome_fake_audio_flags_darwin():
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Darwin"):
flags = chrome_fake_audio_flags(
{"platform": "darwin", "device_name": "BlackHole 2ch"}
)
assert "--use-fake-ui-for-media-stream" in flags
def test_chrome_fake_audio_flags_windows_raises():
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
with patch("plugins.google_meet.audio_bridge.platform.system",
return_value="Windows"):
with pytest.raises(RuntimeError):
chrome_fake_audio_flags({"platform": "windows"})
def test_property_access_before_setup_raises():
from plugins.google_meet.audio_bridge import AudioBridge
br = AudioBridge()
with pytest.raises(RuntimeError):
_ = br.device_name
with pytest.raises(RuntimeError):
_ = br.write_target
+674
View File
@@ -0,0 +1,674 @@
"""Tests for the google_meet node primitive.
Covers protocol helpers, the file-backed registry, the server's
token-and-dispatch machinery, a mocked client, and the CLI plumbing.
We never open a real socket — websockets.serve / websockets.sync.client
are fully mocked.
"""
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
yield hermes_home
# ---------------------------------------------------------------------------
# protocol.py
# ---------------------------------------------------------------------------
def test_protocol_encode_decode_roundtrip():
from plugins.google_meet.node import protocol
msg = protocol.make_request("ping", "tok", {"x": 1}, req_id="abc")
raw = protocol.encode(msg)
out = protocol.decode(raw)
assert out == msg
assert out["type"] == "ping"
assert out["id"] == "abc"
assert out["token"] == "tok"
assert out["payload"] == {"x": 1}
def test_protocol_make_request_autogenerates_id():
from plugins.google_meet.node import protocol
a = protocol.make_request("ping", "tok", {})
b = protocol.make_request("ping", "tok", {})
assert a["id"] != b["id"]
assert len(a["id"]) >= 16 # uuid4 hex
def test_protocol_make_request_rejects_bad_input():
from plugins.google_meet.node import protocol
with pytest.raises(ValueError):
protocol.make_request("", "tok", {})
with pytest.raises(ValueError):
protocol.make_request("unknown_type", "tok", {})
with pytest.raises(ValueError):
protocol.make_request("ping", "tok", "not a dict") # type: ignore[arg-type]
def test_protocol_decode_raises_on_malformed():
from plugins.google_meet.node import protocol
with pytest.raises(ValueError):
protocol.decode("not json at all")
with pytest.raises(ValueError):
protocol.decode("[]") # list, not object
with pytest.raises(ValueError):
protocol.decode(json.dumps({"id": "x"})) # missing type
with pytest.raises(ValueError):
protocol.decode(json.dumps({"type": "ping"})) # missing id
def test_protocol_validate_request_happy_path():
from plugins.google_meet.node import protocol
msg = protocol.make_request("status", "secret", {})
ok, reason = protocol.validate_request(msg, "secret")
assert ok is True
assert reason == ""
def test_protocol_validate_request_rejects_bad_token():
from plugins.google_meet.node import protocol
msg = protocol.make_request("status", "wrong", {})
ok, reason = protocol.validate_request(msg, "right")
assert ok is False
assert "token" in reason.lower()
def test_protocol_validate_request_rejects_unknown_type():
from plugins.google_meet.node import protocol
raw = {"type": "nope", "id": "1", "token": "t", "payload": {}}
ok, reason = protocol.validate_request(raw, "t")
assert ok is False
assert "unknown" in reason.lower()
def test_protocol_validate_request_rejects_missing_id():
from plugins.google_meet.node import protocol
raw = {"type": "ping", "token": "t", "payload": {}}
ok, reason = protocol.validate_request(raw, "t")
assert ok is False
assert "id" in reason.lower()
def test_protocol_validate_request_rejects_non_dict_payload():
from plugins.google_meet.node import protocol
raw = {"type": "ping", "id": "1", "token": "t", "payload": "oops"}
ok, reason = protocol.validate_request(raw, "t")
assert ok is False
def test_protocol_error_envelope_shape():
from plugins.google_meet.node import protocol
err = protocol.make_error("abc", "nope")
assert err == {"type": "error", "id": "abc", "error": "nope"}
# ---------------------------------------------------------------------------
# registry.py
# ---------------------------------------------------------------------------
def test_registry_add_get_roundtrip_persists(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
p = tmp_path / "nodes.json"
r = NodeRegistry(path=p)
r.add("mac", "ws://mac.local:18789", "deadbeef")
# Second instance sees it.
r2 = NodeRegistry(path=p)
entry = r2.get("mac")
assert entry is not None
assert entry["name"] == "mac"
assert entry["url"] == "ws://mac.local:18789"
assert entry["token"] == "deadbeef"
assert "added_at" in entry
def test_registry_get_returns_none_when_missing(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
assert r.get("ghost") is None
def test_registry_remove(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
r.add("a", "ws://a", "t")
assert r.remove("a") is True
assert r.get("a") is None
assert r.remove("a") is False # idempotent
def test_registry_list_all_sorted(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
r.add("zeta", "ws://z", "t1")
r.add("alpha", "ws://a", "t2")
names = [n["name"] for n in r.list_all()]
assert names == ["alpha", "zeta"]
def test_registry_resolve_auto_picks_single(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
r.add("mac", "ws://mac", "t")
picked = r.resolve(None)
assert picked is not None
assert picked["name"] == "mac"
def test_registry_resolve_ambiguous_returns_none(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
r.add("a", "ws://a", "t")
r.add("b", "ws://b", "t")
assert r.resolve(None) is None
def test_registry_resolve_empty_returns_none(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
assert r.resolve(None) is None
def test_registry_resolve_by_name(tmp_path):
from plugins.google_meet.node.registry import NodeRegistry
r = NodeRegistry(path=tmp_path / "n.json")
r.add("a", "ws://a", "t")
r.add("b", "ws://b", "t")
picked = r.resolve("b")
assert picked is not None
assert picked["name"] == "b"
assert r.resolve("ghost") is None
def test_registry_defaults_to_hermes_home(tmp_path, monkeypatch):
from plugins.google_meet.node.registry import NodeRegistry
# _isolate_home already set HERMES_HOME to tmp_path/.hermes; the
# registry default path must live inside that tree.
r = NodeRegistry()
r.add("x", "ws://x", "t")
expected = Path(tmp_path) / ".hermes" / "workspace" / "meetings" / "nodes.json"
assert expected.is_file()
# ---------------------------------------------------------------------------
# server.py — token + dispatch
# ---------------------------------------------------------------------------
def test_server_ensure_token_generates_and_persists(tmp_path):
from plugins.google_meet.node.server import NodeServer
p = tmp_path / "tok.json"
s1 = NodeServer(token_path=p)
t1 = s1.ensure_token()
assert isinstance(t1, str) and len(t1) == 32
# Reuse on a fresh instance.
s2 = NodeServer(token_path=p)
t2 = s2.ensure_token()
assert t1 == t2
data = json.loads(p.read_text(encoding="utf-8"))
assert data["token"] == t1
assert "generated_at" in data
def test_server_get_token_is_idempotent(tmp_path):
from plugins.google_meet.node.server import NodeServer
s = NodeServer(token_path=tmp_path / "t.json")
assert s.get_token() == s.get_token()
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
def test_server_handle_request_rejects_bad_token(tmp_path):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
s = NodeServer(token_path=tmp_path / "t.json")
s.ensure_token()
bad = protocol.make_request("ping", "not-the-token", {})
resp = asyncio.run(s._handle_request(bad))
assert resp["type"] == "error"
assert "token" in resp["error"].lower()
def test_server_handle_request_ping(tmp_path):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
s = NodeServer(token_path=tmp_path / "t.json", display_name="node-x")
tok = s.ensure_token()
req = protocol.make_request("ping", tok, {})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "pong"
assert resp["id"] == req["id"]
assert resp["payload"]["display_name"] == "node-x"
def test_server_handle_request_status_dispatches_to_pm(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
monkeypatch.setattr(pm, "status",
lambda: {"ok": True, "alive": True, "meetingId": "abc"})
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("status", tok, {})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "response"
assert resp["id"] == req["id"]
assert resp["payload"] == {"ok": True, "alive": True, "meetingId": "abc"}
def test_server_handle_request_start_bot_dispatches(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
captured = {}
def fake_start(**kwargs):
captured.update(kwargs)
return {"ok": True, "pid": 42, "meeting_id": "abc-defg-hij"}
monkeypatch.setattr(pm, "start", fake_start)
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("start_bot", tok, {
"url": "https://meet.google.com/abc-defg-hij",
"guest_name": "Bot",
"duration": "30m",
})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "response"
assert resp["payload"]["ok"] is True
assert captured["url"] == "https://meet.google.com/abc-defg-hij"
assert captured["guest_name"] == "Bot"
assert captured["duration"] == "30m"
def test_server_handle_request_start_bot_missing_url(tmp_path):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("start_bot", tok, {"guest_name": "x"})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "error"
assert "url" in resp["error"]
def test_server_handle_request_stop_dispatches(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
got = {}
def fake_stop(*, reason="requested"):
got["reason"] = reason
return {"ok": True, "reason": reason}
monkeypatch.setattr(pm, "stop", fake_stop)
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("stop", tok, {"reason": "user-cancel"})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "response"
assert got["reason"] == "user-cancel"
def test_server_handle_request_transcript(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
got = {}
def fake_transcript(last=None):
got["last"] = last
return {"ok": True, "lines": ["a", "b"], "total": 2}
monkeypatch.setattr(pm, "transcript", fake_transcript)
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("transcript", tok, {"last": 5})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "response"
assert resp["payload"]["lines"] == ["a", "b"]
assert got["last"] == 5
def test_server_handle_request_say_enqueues_when_active(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
out = tmp_path / "meet-out"
out.mkdir()
monkeypatch.setattr(pm, "_read_active",
lambda: {"pid": 1, "meeting_id": "m", "out_dir": str(out)})
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("say", tok, {"text": "hello"})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "response"
assert resp["payload"]["ok"] is True
assert resp["payload"]["enqueued"] is True
q = (out / "say_queue.jsonl").read_text(encoding="utf-8").strip().splitlines()
assert len(q) == 1
assert json.loads(q[0])["text"] == "hello"
def test_server_handle_request_say_without_active_still_ok(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
monkeypatch.setattr(pm, "_read_active", lambda: None)
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("say", tok, {"text": "hi"})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "response"
assert resp["payload"]["ok"] is True
assert resp["payload"]["enqueued"] is False
def test_server_handle_request_wraps_pm_exceptions(tmp_path, monkeypatch):
from plugins.google_meet.node.server import NodeServer
from plugins.google_meet.node import protocol
from plugins.google_meet import process_manager as pm
def boom():
raise ValueError("kaboom")
monkeypatch.setattr(pm, "status", boom)
s = NodeServer(token_path=tmp_path / "t.json")
tok = s.ensure_token()
req = protocol.make_request("status", tok, {})
resp = asyncio.run(s._handle_request(req))
assert resp["type"] == "error"
assert "kaboom" in resp["error"]
# ---------------------------------------------------------------------------
# client.py
# ---------------------------------------------------------------------------
class _FakeWS:
"""Minimal context-manager stand-in for websockets.sync.client.connect."""
def __init__(self, reply_builder):
self._reply_builder = reply_builder
self.sent = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def send(self, raw):
self.sent.append(raw)
def recv(self, timeout=None):
return self._reply_builder(self.sent[-1])
def _install_fake_ws(monkeypatch, reply_builder):
fake_ws_holder = {}
def _connect(url, **kwargs):
ws = _FakeWS(reply_builder)
fake_ws_holder["ws"] = ws
fake_ws_holder["url"] = url
fake_ws_holder["kwargs"] = kwargs
return ws
# Patch the concrete import site inside client._rpc
import websockets.sync.client as wsc # type: ignore
monkeypatch.setattr(wsc, "connect", _connect)
return fake_ws_holder
def test_client_rpc_sends_correct_envelope_and_parses_response(monkeypatch):
from plugins.google_meet.node.client import NodeClient
from plugins.google_meet.node import protocol
def reply(raw_out):
req = protocol.decode(raw_out)
return protocol.encode(protocol.make_response(req["id"], {"ok": True, "echo": req["type"]}))
holder = _install_fake_ws(monkeypatch, reply)
c = NodeClient("ws://remote:1", "tok123")
out = c._rpc("ping", {"hello": 1})
assert out == {"ok": True, "echo": "ping"}
sent = json.loads(holder["ws"].sent[0])
assert sent["type"] == "ping"
assert sent["token"] == "tok123"
assert sent["payload"] == {"hello": 1}
assert sent["id"] # non-empty
assert holder["url"] == "ws://remote:1"
def test_client_rpc_raises_on_error_envelope(monkeypatch):
from plugins.google_meet.node.client import NodeClient
from plugins.google_meet.node import protocol
def reply(raw_out):
req = protocol.decode(raw_out)
return protocol.encode(protocol.make_error(req["id"], "nope"))
_install_fake_ws(monkeypatch, reply)
c = NodeClient("ws://x", "t")
with pytest.raises(RuntimeError, match="nope"):
c._rpc("ping", {})
def test_client_rpc_raises_on_id_mismatch(monkeypatch):
from plugins.google_meet.node.client import NodeClient
from plugins.google_meet.node import protocol
def reply(raw_out):
return protocol.encode(protocol.make_response("different-id", {"ok": True}))
_install_fake_ws(monkeypatch, reply)
c = NodeClient("ws://x", "t")
with pytest.raises(RuntimeError, match="mismatch"):
c._rpc("ping", {})
def test_client_convenience_methods_hit_correct_types(monkeypatch):
from plugins.google_meet.node.client import NodeClient
from plugins.google_meet.node import protocol
seen = []
def reply(raw_out):
req = protocol.decode(raw_out)
seen.append((req["type"], req["payload"]))
return protocol.encode(protocol.make_response(req["id"], {"ok": True}))
_install_fake_ws(monkeypatch, reply)
c = NodeClient("ws://x", "t")
c.start_bot("https://meet.google.com/a-b-c", guest_name="G", duration="10m")
c.stop()
c.status()
c.transcript(last=3)
c.say("hi")
c.ping()
types = [t for t, _ in seen]
assert types == ["start_bot", "stop", "status", "transcript", "say", "ping"]
# Check specific payload routing
assert seen[0][1]["url"] == "https://meet.google.com/a-b-c"
assert seen[0][1]["guest_name"] == "G"
assert seen[0][1]["duration"] == "10m"
assert seen[3][1]["last"] == 3
assert seen[4][1]["text"] == "hi"
def test_client_init_rejects_bad_args():
from plugins.google_meet.node.client import NodeClient
with pytest.raises(ValueError):
NodeClient("", "t")
with pytest.raises(ValueError):
NodeClient("ws://x", "")
# ---------------------------------------------------------------------------
# cli.py
# ---------------------------------------------------------------------------
def _build_parser():
from plugins.google_meet.node.cli import register_cli
parser = argparse.ArgumentParser(prog="meet-node-test")
register_cli(parser)
return parser
def test_cli_approve_list_remove(capsys):
from plugins.google_meet.node.registry import NodeRegistry
p = _build_parser()
args = p.parse_args(["approve", "mac", "ws://mac:1", "tok"])
rc = args.func(args)
assert rc == 0
assert NodeRegistry().get("mac") is not None
args = p.parse_args(["list"])
rc = args.func(args)
assert rc == 0
out = capsys.readouterr().out
assert "mac" in out
assert "ws://mac:1" in out
args = p.parse_args(["remove", "mac"])
rc = args.func(args)
assert rc == 0
assert NodeRegistry().get("mac") is None
def test_cli_list_empty(capsys):
p = _build_parser()
args = p.parse_args(["list"])
rc = args.func(args)
assert rc == 0
assert "no nodes" in capsys.readouterr().out
def test_cli_remove_missing_returns_nonzero():
p = _build_parser()
args = p.parse_args(["remove", "ghost"])
rc = args.func(args)
assert rc == 1
def test_cli_status_pings_via_node_client(capsys, monkeypatch):
from plugins.google_meet.node.registry import NodeRegistry
from plugins.google_meet.node import cli as node_cli
NodeRegistry().add("mac", "ws://mac:1", "tok")
class _FakeClient:
def __init__(self, url, token):
assert url == "ws://mac:1"
assert token == "tok"
def ping(self):
return {"type": "pong", "display_name": "hermes-meet-node"}
monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
p = _build_parser()
args = p.parse_args(["status", "mac"])
rc = args.func(args)
assert rc == 0
out = capsys.readouterr().out.strip()
data = json.loads(out)
assert data["ok"] is True
assert data["node"] == "mac"
def test_cli_status_unknown_node_fails(capsys):
p = _build_parser()
args = p.parse_args(["status", "ghost"])
rc = args.func(args)
assert rc == 1
def test_cli_status_reports_client_error(capsys, monkeypatch):
from plugins.google_meet.node.registry import NodeRegistry
from plugins.google_meet.node import cli as node_cli
NodeRegistry().add("mac", "ws://mac:1", "tok")
class _FakeClient:
def __init__(self, url, token):
pass
def ping(self):
raise RuntimeError("connection refused")
monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
p = _build_parser()
args = p.parse_args(["status", "mac"])
rc = args.func(args)
assert rc == 1
data = json.loads(capsys.readouterr().out.strip())
assert data["ok"] is False
assert "connection refused" in data["error"]
+815
View File
@@ -0,0 +1,815 @@
"""Tests for the google_meet plugin.
Covers the safety-gated pieces that don't require Playwright:
* URL regex — only ``https://meet.google.com/`` URLs pass
* Meeting-id extraction from Meet URLs
* Status / transcript writes round-trip through the file-backed state
* Tool handlers return well-formed JSON under all branches
* Process manager refuses unsafe URLs and clears stale state cleanly
* ``_on_session_end`` hook is defensive (no-ops when no bot active)
Does NOT spawn a real Chromium — we mock ``subprocess.Popen`` where needed.
"""
from __future__ import annotations
import json
import os
import signal
from pathlib import Path
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
yield hermes_home
# ---------------------------------------------------------------------------
# URL safety gate
# ---------------------------------------------------------------------------
def test_is_safe_meet_url_accepts_standard_meet_codes():
from plugins.google_meet.meet_bot import _is_safe_meet_url
assert _is_safe_meet_url("https://meet.google.com/abc-defg-hij")
assert _is_safe_meet_url("https://meet.google.com/abc-defg-hij?pli=1")
assert _is_safe_meet_url("https://meet.google.com/new")
assert _is_safe_meet_url("https://meet.google.com/lookup/ABC123")
def test_is_safe_meet_url_rejects_non_meet_urls():
from plugins.google_meet.meet_bot import _is_safe_meet_url
# wrong host
assert not _is_safe_meet_url("https://evil.example.com/abc-defg-hij")
# wrong scheme
assert not _is_safe_meet_url("http://meet.google.com/abc-defg-hij")
# malformed code
assert not _is_safe_meet_url("https://meet.google.com/not-a-meet-code")
# subdomain hijack attempts
assert not _is_safe_meet_url("https://meet.google.com.evil.com/abc-defg-hij")
assert not _is_safe_meet_url("https://notmeet.google.com/abc-defg-hij")
# empty / wrong type
assert not _is_safe_meet_url("")
assert not _is_safe_meet_url(None) # type: ignore[arg-type]
assert not _is_safe_meet_url(123) # type: ignore[arg-type]
def test_meeting_id_extraction():
from plugins.google_meet.meet_bot import _meeting_id_from_url
assert _meeting_id_from_url("https://meet.google.com/abc-defg-hij") == "abc-defg-hij"
assert _meeting_id_from_url("https://meet.google.com/abc-defg-hij?pli=1") == "abc-defg-hij"
# fallback for codes we can't parse (e.g. /new before redirect)
fallback = _meeting_id_from_url("https://meet.google.com/new")
assert fallback.startswith("meet-")
# ---------------------------------------------------------------------------
# _BotState — transcript + status file round-trip
# ---------------------------------------------------------------------------
def test_bot_state_dedupes_captions_and_flushes_status(tmp_path):
from plugins.google_meet.meet_bot import _BotState
out = tmp_path / "session"
state = _BotState(out_dir=out, meeting_id="abc-defg-hij",
url="https://meet.google.com/abc-defg-hij")
state.record_caption("Alice", "Hey everyone")
state.record_caption("Alice", "Hey everyone") # dup — ignored
state.record_caption("Bob", "Let's start")
transcript = (out / "transcript.txt").read_text()
assert "Alice: Hey everyone" in transcript
assert "Bob: Let's start" in transcript
# dedup — Alice line appears exactly once
assert transcript.count("Alice: Hey everyone") == 1
status = json.loads((out / "status.json").read_text())
assert status["meetingId"] == "abc-defg-hij"
assert status["transcriptLines"] == 2
assert status["transcriptPath"].endswith("transcript.txt")
def test_bot_state_ignores_blank_text(tmp_path):
from plugins.google_meet.meet_bot import _BotState
state = _BotState(out_dir=tmp_path / "s", meeting_id="x-y-z",
url="https://meet.google.com/x-y-z")
state.record_caption("Alice", "")
state.record_caption("Alice", " ")
state.record_caption("", "text but no speaker")
status = json.loads((tmp_path / "s" / "status.json").read_text())
assert status["transcriptLines"] == 1
# blank-speaker falls back to "Unknown"
assert "Unknown: text but no speaker" in (tmp_path / "s" / "transcript.txt").read_text()
def test_parse_duration():
from plugins.google_meet.meet_bot import _parse_duration
assert _parse_duration("30m") == 30 * 60
assert _parse_duration("2h") == 2 * 3600
assert _parse_duration("90s") == 90
assert _parse_duration("90") == 90
assert _parse_duration("") is None
assert _parse_duration("bogus") is None
# ---------------------------------------------------------------------------
# process_manager — refuses unsafe URLs, manages active pointer
# ---------------------------------------------------------------------------
def test_start_refuses_unsafe_url():
from plugins.google_meet import process_manager as pm
res = pm.start("https://evil.example.com/abc-defg-hij")
assert res["ok"] is False
assert "refusing" in res["error"]
def test_status_reports_no_active_meeting():
from plugins.google_meet import process_manager as pm
assert pm.status() == {"ok": False, "reason": "no active meeting"}
assert pm.transcript() == {"ok": False, "reason": "no active meeting"}
assert pm.stop() == {"ok": False, "reason": "no active meeting"}
def test_start_spawns_subprocess_and_writes_active_pointer(tmp_path):
"""Verify start() wires env vars correctly and records the pid."""
from plugins.google_meet import process_manager as pm
class _FakeProc:
def __init__(self, pid):
self.pid = pid
captured_env = {}
captured_argv = []
def _fake_popen(argv, **kwargs):
captured_argv.extend(argv)
captured_env.update(kwargs.get("env") or {})
return _FakeProc(99999)
with patch.object(pm.subprocess, "Popen", side_effect=_fake_popen):
# Also prevent pid liveness probe from stomping on our real pids
with patch.object(pm, "_pid_alive", return_value=False):
res = pm.start(
"https://meet.google.com/abc-defg-hij",
guest_name="Test Bot",
duration="15m",
)
assert res["ok"] is True
assert res["meeting_id"] == "abc-defg-hij"
assert res["pid"] == 99999
assert captured_env["HERMES_MEET_URL"] == "https://meet.google.com/abc-defg-hij"
assert captured_env["HERMES_MEET_GUEST_NAME"] == "Test Bot"
assert captured_env["HERMES_MEET_DURATION"] == "15m"
# python -m plugins.google_meet.meet_bot
assert any("plugins.google_meet.meet_bot" in a for a in captured_argv)
# .active.json points at the bot
active = pm._read_active()
assert active is not None
assert active["pid"] == 99999
assert active["meeting_id"] == "abc-defg-hij"
def test_transcript_reads_last_n_lines(tmp_path):
from plugins.google_meet import process_manager as pm
meeting_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
meeting_dir.mkdir(parents=True)
(meeting_dir / "transcript.txt").write_text(
"[10:00:00] Alice: one\n"
"[10:00:01] Bob: two\n"
"[10:00:02] Alice: three\n"
)
pm._write_active({
"pid": 0, "meeting_id": "abc-defg-hij",
"out_dir": str(meeting_dir),
"url": "https://meet.google.com/abc-defg-hij",
"started_at": 0,
})
res = pm.transcript(last=2)
assert res["ok"] is True
assert res["total"] == 3
assert len(res["lines"]) == 2
assert res["lines"][-1].endswith("Alice: three")
def test_stop_signals_process_and_clears_pointer(tmp_path):
from plugins.google_meet import process_manager as pm
pm._write_active({
"pid": 11111, "meeting_id": "x-y-z",
"out_dir": str(tmp_path / "x-y-z"),
"url": "https://meet.google.com/x-y-z",
"started_at": 0,
})
alive_seq = iter([True, True, False]) # alive at first, gone after SIGTERM
def _alive(pid):
try:
return next(alive_seq)
except StopIteration:
return False
sent = []
def _kill(pid, sig):
sent.append((pid, sig))
with patch.object(pm, "_pid_alive", side_effect=_alive), \
patch.object(pm.os, "kill", side_effect=_kill), \
patch.object(pm.time, "sleep", lambda _s: None):
res = pm.stop()
assert res["ok"] is True
assert (11111, signal.SIGTERM) in sent
# .active.json cleared
assert pm._read_active() is None
# ---------------------------------------------------------------------------
# Tool handlers — JSON shape + safety gates
# ---------------------------------------------------------------------------
def test_meet_join_handler_missing_url_returns_error():
from plugins.google_meet.tools import handle_meet_join
out = json.loads(handle_meet_join({}))
assert out["success"] is False
assert "url is required" in out["error"]
def test_meet_join_handler_respects_safety_gate():
from plugins.google_meet.tools import handle_meet_join
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=True):
out = json.loads(handle_meet_join({"url": "https://evil.example.com/foo"}))
assert out["success"] is False
assert "refusing" in out["error"]
def test_meet_join_handler_returns_error_when_playwright_missing():
from plugins.google_meet.tools import handle_meet_join
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=False):
out = json.loads(handle_meet_join({"url": "https://meet.google.com/abc-defg-hij"}))
assert out["success"] is False
assert "prerequisites missing" in out["error"]
def test_meet_say_requires_text():
from plugins.google_meet.tools import handle_meet_say
out = json.loads(handle_meet_say({}))
assert out["success"] is False
assert "text is required" in out["error"]
def test_meet_say_no_active_meeting():
from plugins.google_meet.tools import handle_meet_say
out = json.loads(handle_meet_say({"text": "hello everyone"}))
assert out["success"] is False
# Falls through to pm.enqueue_say which reports no active meeting.
assert "no active meeting" in out.get("reason", "")
def test_meet_status_and_transcript_no_active():
from plugins.google_meet.tools import handle_meet_status, handle_meet_transcript
assert json.loads(handle_meet_status({}))["success"] is False
assert json.loads(handle_meet_transcript({}))["success"] is False
def test_meet_leave_no_active():
from plugins.google_meet.tools import handle_meet_leave
out = json.loads(handle_meet_leave({}))
assert out["success"] is False
# ---------------------------------------------------------------------------
# _on_session_end — defensive cleanup
# ---------------------------------------------------------------------------
def test_on_session_end_noop_when_nothing_active():
from plugins.google_meet import _on_session_end
# Should not raise and should not call stop().
with patch("plugins.google_meet.pm.stop") as stop_mock:
_on_session_end()
stop_mock.assert_not_called()
def test_on_session_end_stops_live_bot():
from plugins.google_meet import _on_session_end
from plugins.google_meet import pm
with patch.object(pm, "status", return_value={"ok": True, "alive": True}), \
patch.object(pm, "stop") as stop_mock:
_on_session_end()
stop_mock.assert_called_once()
# ---------------------------------------------------------------------------
# Plugin register() — platform gating + tool registration
# ---------------------------------------------------------------------------
def test_register_refuses_on_windows():
import plugins.google_meet as plugin
calls = {"tools": [], "cli": [], "hooks": []}
class _Ctx:
def register_tool(self, **kw): calls["tools"].append(kw["name"])
def register_cli_command(self, **kw): calls["cli"].append(kw["name"])
def register_hook(self, name, fn): calls["hooks"].append(name)
with patch.object(plugin.platform, "system", return_value="Windows"):
plugin.register(_Ctx())
assert calls == {"tools": [], "cli": [], "hooks": []}
def test_register_wires_tools_cli_and_hook_on_linux():
import plugins.google_meet as plugin
calls = {"tools": [], "cli": [], "hooks": []}
class _Ctx:
def register_tool(self, **kw): calls["tools"].append(kw["name"])
def register_cli_command(self, **kw): calls["cli"].append(kw["name"])
def register_hook(self, name, fn): calls["hooks"].append(name)
with patch.object(plugin.platform, "system", return_value="Linux"):
plugin.register(_Ctx())
assert set(calls["tools"]) == {
"meet_join", "meet_status", "meet_transcript", "meet_leave", "meet_say",
}
assert calls["cli"] == ["meet"]
assert calls["hooks"] == ["on_session_end"]
# ---------------------------------------------------------------------------
# v2: process_manager.enqueue_say + realtime-mode passthrough
# ---------------------------------------------------------------------------
def test_enqueue_say_requires_text():
from plugins.google_meet import process_manager as pm
assert pm.enqueue_say("")["ok"] is False
assert pm.enqueue_say(" ")["ok"] is False
def test_enqueue_say_no_active_meeting():
from plugins.google_meet import process_manager as pm
res = pm.enqueue_say("hi team")
assert res["ok"] is False
assert "no active meeting" in res["reason"]
def test_enqueue_say_rejects_transcribe_mode(tmp_path):
from plugins.google_meet import process_manager as pm
out_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
out_dir.mkdir(parents=True)
pm._write_active({
"pid": 0, "meeting_id": "abc-defg-hij",
"out_dir": str(out_dir), "url": "https://meet.google.com/abc-defg-hij",
"started_at": 0, "mode": "transcribe",
})
res = pm.enqueue_say("hi team")
assert res["ok"] is False
assert "transcribe mode" in res["reason"]
def test_enqueue_say_writes_jsonl_in_realtime_mode():
from plugins.google_meet import process_manager as pm
out_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
out_dir.mkdir(parents=True)
pm._write_active({
"pid": 0, "meeting_id": "abc-defg-hij",
"out_dir": str(out_dir), "url": "https://meet.google.com/abc-defg-hij",
"started_at": 0, "mode": "realtime",
})
res = pm.enqueue_say("hello everyone")
assert res["ok"] is True
assert "enqueued_id" in res
queue = out_dir / "say_queue.jsonl"
assert queue.is_file()
lines = [json.loads(ln) for ln in queue.read_text().splitlines() if ln.strip()]
assert len(lines) == 1
assert lines[0]["text"] == "hello everyone"
def test_start_passes_mode_into_active_record():
from plugins.google_meet import process_manager as pm
class _FakeProc:
def __init__(self, pid): self.pid = pid
with patch.object(pm.subprocess, "Popen", return_value=_FakeProc(12345)), \
patch.object(pm, "_pid_alive", return_value=False):
res = pm.start(
"https://meet.google.com/abc-defg-hij",
mode="realtime",
)
assert res["ok"] is True
assert res["mode"] == "realtime"
assert pm._read_active()["mode"] == "realtime"
def test_start_realtime_env_vars_threaded_through():
from plugins.google_meet import process_manager as pm
class _FakeProc:
def __init__(self, pid): self.pid = pid
captured_env = {}
def _fake_popen(argv, **kwargs):
captured_env.update(kwargs.get("env") or {})
return _FakeProc(11111)
with patch.object(pm.subprocess, "Popen", side_effect=_fake_popen), \
patch.object(pm, "_pid_alive", return_value=False):
pm.start(
"https://meet.google.com/abc-defg-hij",
mode="realtime",
realtime_model="gpt-realtime",
realtime_voice="alloy",
realtime_instructions="Be brief.",
realtime_api_key="sk-test",
)
assert captured_env["HERMES_MEET_MODE"] == "realtime"
assert captured_env["HERMES_MEET_REALTIME_MODEL"] == "gpt-realtime"
assert captured_env["HERMES_MEET_REALTIME_VOICE"] == "alloy"
assert captured_env["HERMES_MEET_REALTIME_INSTRUCTIONS"] == "Be brief."
assert captured_env["HERMES_MEET_REALTIME_KEY"] == "sk-test"
def test_meet_join_accepts_realtime_mode():
from plugins.google_meet.tools import handle_meet_join
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=True), \
patch("plugins.google_meet.tools.pm.start", return_value={"ok": True, "meeting_id": "x-y-z"}) as start_mock:
out = json.loads(handle_meet_join({
"url": "https://meet.google.com/abc-defg-hij",
"mode": "realtime",
}))
assert out["success"] is True
assert start_mock.call_args.kwargs["mode"] == "realtime"
def test_meet_join_rejects_bad_mode():
from plugins.google_meet.tools import handle_meet_join
out = json.loads(handle_meet_join({
"url": "https://meet.google.com/abc-defg-hij",
"mode": "bogus",
}))
assert out["success"] is False
assert "mode must be" in out["error"]
# ---------------------------------------------------------------------------
# v3: NodeClient routing from tool handlers
# ---------------------------------------------------------------------------
def test_meet_join_unknown_node_returns_clear_error():
from plugins.google_meet.tools import handle_meet_join
out = json.loads(handle_meet_join({
"url": "https://meet.google.com/abc-defg-hij",
"node": "my-mac",
}))
assert out["success"] is False
assert "no registered meet node" in out["error"]
def test_meet_join_routes_to_registered_node():
from plugins.google_meet.tools import handle_meet_join
from plugins.google_meet.node.registry import NodeRegistry
reg = NodeRegistry()
reg.add("my-mac", "ws://1.2.3.4:18789", "tok")
with patch("plugins.google_meet.node.client.NodeClient.start_bot",
return_value={"ok": True, "meeting_id": "a-b-c"}) as call_mock:
out = json.loads(handle_meet_join({
"url": "https://meet.google.com/abc-defg-hij",
"node": "my-mac",
"mode": "realtime",
}))
assert out["success"] is True
assert out["node"] == "my-mac"
assert call_mock.call_args.kwargs["mode"] == "realtime"
def test_meet_say_routes_to_node():
from plugins.google_meet.tools import handle_meet_say
from plugins.google_meet.node.registry import NodeRegistry
reg = NodeRegistry()
reg.add("my-mac", "ws://1.2.3.4:18789", "tok")
with patch("plugins.google_meet.node.client.NodeClient.say",
return_value={"ok": True, "enqueued_id": "abc"}) as call_mock:
out = json.loads(handle_meet_say({"text": "hello", "node": "my-mac"}))
assert out["success"] is True
assert out["node"] == "my-mac"
call_mock.assert_called_once_with("hello")
def test_meet_join_auto_node_selects_sole_registered():
from plugins.google_meet.tools import handle_meet_join
from plugins.google_meet.node.registry import NodeRegistry
reg = NodeRegistry()
reg.add("only-one", "ws://1.2.3.4:18789", "tok")
with patch("plugins.google_meet.node.client.NodeClient.start_bot",
return_value={"ok": True}) as call_mock:
out = json.loads(handle_meet_join({
"url": "https://meet.google.com/abc-defg-hij",
"node": "auto",
}))
assert out["success"] is True
assert out["node"] == "only-one"
assert call_mock.called
def test_meet_join_auto_node_ambiguous_returns_error():
from plugins.google_meet.tools import handle_meet_join
from plugins.google_meet.node.registry import NodeRegistry
reg = NodeRegistry()
reg.add("a", "ws://1.2.3.4:18789", "tok")
reg.add("b", "ws://5.6.7.8:18789", "tok")
out = json.loads(handle_meet_join({
"url": "https://meet.google.com/abc-defg-hij",
"node": "auto",
}))
assert out["success"] is False
assert "no registered meet node" in out["error"]
def test_cli_register_includes_node_subcommand():
"""`hermes meet` argparse tree includes the node subtree."""
import argparse
from plugins.google_meet.cli import register_cli
parser = argparse.ArgumentParser(prog="hermes meet")
register_cli(parser)
# Parse a known-good node invocation to prove the subtree is wired.
ns = parser.parse_args(["node", "list"])
assert ns.meet_command == "node"
assert ns.node_cmd == "list"
def test_cli_join_accepts_mode_and_node_flags():
import argparse
from plugins.google_meet.cli import register_cli
parser = argparse.ArgumentParser(prog="hermes meet")
register_cli(parser)
ns = parser.parse_args([
"join", "https://meet.google.com/abc-defg-hij",
"--mode", "realtime", "--node", "my-mac",
])
assert ns.mode == "realtime"
assert ns.node == "my-mac"
def test_cli_say_subcommand_exists():
import argparse
from plugins.google_meet.cli import register_cli
parser = argparse.ArgumentParser(prog="hermes meet")
register_cli(parser)
ns = parser.parse_args(["say", "hello team", "--node", "my-mac"])
assert ns.text == "hello team"
assert ns.node == "my-mac"
# ---------------------------------------------------------------------------
# v2.1: new _BotState fields + status dict shape
# ---------------------------------------------------------------------------
def test_bot_state_exposes_v2_telemetry_fields(tmp_path):
from plugins.google_meet.meet_bot import _BotState
state = _BotState(out_dir=tmp_path / "s", meeting_id="x-y-z",
url="https://meet.google.com/x-y-z")
# Defaults for the new fields.
status = json.loads((tmp_path / "s" / "status.json").read_text())
for key in (
"realtime", "realtimeReady", "realtimeDevice",
"audioBytesOut", "lastAudioOutAt", "lastBargeInAt",
"joinAttemptedAt", "leaveReason",
):
assert key in status, f"missing v2 telemetry key: {key}"
assert status["realtime"] is False
assert status["realtimeReady"] is False
assert status["audioBytesOut"] == 0
# Setting them flushes them.
state.set(realtime=True, realtime_ready=True, audio_bytes_out=1024,
leave_reason="lobby_timeout")
status = json.loads((tmp_path / "s" / "status.json").read_text())
assert status["realtime"] is True
assert status["realtimeReady"] is True
assert status["audioBytesOut"] == 1024
assert status["leaveReason"] == "lobby_timeout"
# ---------------------------------------------------------------------------
# Admission detection + barge-in helper
# ---------------------------------------------------------------------------
def test_looks_like_human_speaker():
from plugins.google_meet.meet_bot import _looks_like_human_speaker
# Blank, "unknown", "you", and the bot's own name → not human (no barge-in)
for s in ("", " ", "Unknown", "unknown", "You", "you", "Hermes Agent", "hermes agent"):
assert not _looks_like_human_speaker(s, "Hermes Agent"), f"{s!r} should NOT be human"
# Real names → human (barge-in)
for s in ("Alice", "Bob Lee", "@teknium"):
assert _looks_like_human_speaker(s, "Hermes Agent"), f"{s!r} SHOULD be human"
def test_detect_admission_returns_false_on_error():
from plugins.google_meet.meet_bot import _detect_admission
class _FakePage:
def evaluate(self, _js): raise RuntimeError("boom")
assert _detect_admission(_FakePage()) is False
def test_detect_admission_true_when_probe_returns_true():
from plugins.google_meet.meet_bot import _detect_admission
class _FakePage:
def evaluate(self, _js): return True
assert _detect_admission(_FakePage()) is True
def test_detect_denied_returns_false_on_error():
from plugins.google_meet.meet_bot import _detect_denied
class _FakePage:
def evaluate(self, _js): raise RuntimeError("boom")
assert _detect_denied(_FakePage()) is False
# ---------------------------------------------------------------------------
# Realtime session counters + cancel_response (barge-in)
# ---------------------------------------------------------------------------
def test_realtime_session_cancel_response_when_disconnected():
from plugins.google_meet.realtime.openai_client import RealtimeSession
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
# No _ws yet — cancel should no-op and return False.
assert sess.cancel_response() is False
def test_realtime_session_cancel_response_sends_cancel_frame():
from plugins.google_meet.realtime.openai_client import RealtimeSession
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
sent = []
class _FakeWs:
def send(self, msg): sent.append(msg)
sess._ws = _FakeWs()
assert sess.cancel_response() is True
assert len(sent) == 1
import json as _j
envelope = _j.loads(sent[0])
assert envelope == {"type": "response.cancel"}
def test_realtime_session_counters_initialized():
from plugins.google_meet.realtime.openai_client import RealtimeSession
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
assert sess.audio_bytes_out == 0
assert sess.last_audio_out_at is None
# ---------------------------------------------------------------------------
# hermes meet install CLI
# ---------------------------------------------------------------------------
def test_cli_install_subcommand_is_registered():
import argparse
from plugins.google_meet.cli import register_cli
parser = argparse.ArgumentParser(prog="hermes meet")
register_cli(parser)
ns = parser.parse_args(["install"])
assert ns.meet_command == "install"
assert ns.realtime is False
assert ns.yes is False
def test_cli_install_flags_parse():
import argparse
from plugins.google_meet.cli import register_cli
parser = argparse.ArgumentParser(prog="hermes meet")
register_cli(parser)
ns = parser.parse_args(["install", "--realtime", "--yes"])
assert ns.realtime is True
assert ns.yes is True
def test_cmd_install_refuses_windows(capsys):
from plugins.google_meet.cli import _cmd_install
with patch("plugins.google_meet.cli.platform" if False else "platform.system",
return_value="Windows"):
rc = _cmd_install(realtime=False, assume_yes=True)
assert rc == 1
out = capsys.readouterr().out
assert "Windows" in out
def test_cmd_install_runs_pip_and_playwright(capsys):
"""End-to-end wiring: pip + playwright install invoked, returncodes handled."""
from plugins.google_meet.cli import _cmd_install
calls = []
class _FakeRes:
def __init__(self, rc=0): self.returncode = rc
def _fake_run(argv, **kwargs):
calls.append(list(argv))
return _FakeRes(0)
with patch("platform.system", return_value="Linux"), \
patch("subprocess.run", side_effect=_fake_run), \
patch("shutil.which", return_value="/usr/bin/paplay"):
rc = _cmd_install(realtime=False, assume_yes=True)
assert rc == 0
# First invocation: dependency install via the uv→pip ladder
# (shutil.which is mocked truthy, so the uv tier is taken: `<uv> pip install ...`)
pip_cmds = [
c for c in calls
if "install" in c and "playwright" in c and "websockets" in c
]
assert pip_cmds, f"no dependency install run: {calls}"
# Second: playwright install chromium
pw_cmds = [c for c in calls if len(c) > 2 and c[1:4] == ["-m", "playwright", "install"]]
assert pw_cmds, f"no playwright install run: {calls}"
assert "chromium" in pw_cmds[0]
def test_cmd_install_realtime_skips_when_deps_present(capsys):
"""When paplay + pactl are already on PATH, no sudo call happens."""
from plugins.google_meet.cli import _cmd_install
calls = []
class _FakeRes:
def __init__(self, rc=0): self.returncode = rc
def _fake_run(argv, **kwargs):
calls.append(list(argv))
return _FakeRes(0)
with patch("platform.system", return_value="Linux"), \
patch("subprocess.run", side_effect=_fake_run), \
patch("shutil.which", return_value="/usr/bin/paplay"):
rc = _cmd_install(realtime=True, assume_yes=True)
assert rc == 0
# No sudo apt-get call — paplay was already on PATH.
sudo_calls = [c for c in calls if c and c[0] == "sudo"]
assert sudo_calls == [], f"unexpected sudo invocation: {sudo_calls}"
out = capsys.readouterr().out
assert "already installed" in out
+290
View File
@@ -0,0 +1,290 @@
"""Tests for plugins.google_meet.realtime.openai_client (v2).
Uses a scripted fake WebSocket — no network, no API key required.
"""
from __future__ import annotations
import base64
import json
import sys
import types
import pytest
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
yield hermes_home
# ---------------------------------------------------------------------------
# Fake WebSocket
# ---------------------------------------------------------------------------
class _FakeWS:
"""Scripted WS: send() records frames, recv() pops a queue."""
def __init__(self, recv_frames: list):
self.sent: list[dict] = []
self._recv_q: list = list(recv_frames)
self.closed = False
def send(self, payload):
# Always accept str payloads — client encodes JSON with json.dumps.
if isinstance(payload, (bytes, bytearray)):
payload = payload.decode()
self.sent.append(json.loads(payload))
def recv(self, timeout=None): # noqa: ARG002
if not self._recv_q:
raise RuntimeError("fake ws: no more frames")
frame = self._recv_q.pop(0)
if isinstance(frame, dict):
return json.dumps(frame)
return frame
def close(self):
self.closed = True
def _install_fake_websockets(monkeypatch, fake_ws):
"""Install a fake ``websockets.sync.client`` module in sys.modules."""
mod_websockets = types.ModuleType("websockets")
mod_sync = types.ModuleType("websockets.sync")
mod_sync_client = types.ModuleType("websockets.sync.client")
captured = {"url": None, "headers": None, "kwargs": None}
def _connect(url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
captured["headers"] = (
kwargs.get("additional_headers") or kwargs.get("extra_headers")
)
return fake_ws
mod_sync_client.connect = _connect
mod_sync.client = mod_sync_client
mod_websockets.sync = mod_sync
monkeypatch.setitem(sys.modules, "websockets", mod_websockets)
monkeypatch.setitem(sys.modules, "websockets.sync", mod_sync)
monkeypatch.setitem(sys.modules, "websockets.sync.client", mod_sync_client)
return captured
# ---------------------------------------------------------------------------
# connect()
# ---------------------------------------------------------------------------
def test_connect_sends_session_update_with_voice_and_instructions(monkeypatch):
from plugins.google_meet.realtime.openai_client import RealtimeSession
ws = _FakeWS(recv_frames=[])
captured = _install_fake_websockets(monkeypatch, ws)
sess = RealtimeSession(
api_key="sk-test",
model="gpt-realtime",
voice="verse",
instructions="Be brief.",
)
sess.connect()
# Auth + beta headers set.
assert captured["url"].startswith("wss://api.openai.com/v1/realtime")
assert "model=gpt-realtime" in captured["url"]
headers = captured["headers"] or []
hdict = dict(headers)
assert hdict.get("Authorization") == "Bearer sk-test"
assert hdict.get("OpenAI-Beta") == "realtime=v1"
# First frame sent must be session.update with the right shape.
assert len(ws.sent) == 1
update = ws.sent[0]
assert update["type"] == "session.update"
s = update["session"]
assert s["voice"] == "verse"
assert s["instructions"] == "Be brief."
assert set(s["modalities"]) == {"audio", "text"}
assert s["output_audio_format"] == "pcm16"
assert s["input_audio_format"] == "pcm16"
# ---------------------------------------------------------------------------
# speak()
# ---------------------------------------------------------------------------
def test_speak_sends_create_and_response_and_writes_audio(monkeypatch, tmp_path):
from plugins.google_meet.realtime.openai_client import RealtimeSession
audio_bytes = b"\x01\x02\x03\x04PCM!"
b64 = base64.b64encode(audio_bytes).decode()
recv_frames = [
{"type": "response.created"},
{"type": "response.audio.delta", "delta": b64},
{"type": "response.audio.delta", "delta": base64.b64encode(b"more").decode()},
{"type": "response.done"},
]
ws = _FakeWS(recv_frames=recv_frames)
_install_fake_websockets(monkeypatch, ws)
sink = tmp_path / "out.pcm"
sess = RealtimeSession(api_key="sk-test", audio_sink_path=sink)
sess.connect()
result = sess.speak("Hello everyone.")
# Frames sent after session.update: conversation.item.create then response.create.
types_sent = [f["type"] for f in ws.sent]
assert types_sent == ["session.update", "conversation.item.create", "response.create"]
item = ws.sent[1]["item"]
assert item["role"] == "user"
assert item["content"][0]["type"] == "input_text"
assert item["content"][0]["text"] == "Hello everyone."
resp = ws.sent[2]["response"]
assert resp["modalities"] == ["audio"]
# Audio file got decoded + appended bytes.
data = sink.read_bytes()
assert data == audio_bytes + b"more"
assert result["ok"] is True
assert result["bytes_written"] == len(audio_bytes) + len(b"more")
assert result["duration_ms"] >= 0.0
def test_speak_raises_on_error_frame(monkeypatch, tmp_path):
from plugins.google_meet.realtime.openai_client import RealtimeSession
ws = _FakeWS(recv_frames=[
{"type": "response.created"},
{"type": "error", "error": {"message": "bad juju"}},
])
_install_fake_websockets(monkeypatch, ws)
sess = RealtimeSession(api_key="sk-test", audio_sink_path=tmp_path / "o.pcm")
sess.connect()
with pytest.raises(RuntimeError, match="bad juju"):
sess.speak("hi")
def test_speak_without_connect_raises(monkeypatch):
from plugins.google_meet.realtime.openai_client import RealtimeSession
sess = RealtimeSession(api_key="sk-test")
with pytest.raises(RuntimeError, match="connect"):
sess.speak("hi")
def test_close_is_idempotent_and_closes_ws(monkeypatch):
from plugins.google_meet.realtime.openai_client import RealtimeSession
ws = _FakeWS(recv_frames=[])
_install_fake_websockets(monkeypatch, ws)
sess = RealtimeSession(api_key="sk-test")
sess.connect()
sess.close()
assert ws.closed is True
# Second close is a no-op.
sess.close()
# ---------------------------------------------------------------------------
# websockets dependency missing
# ---------------------------------------------------------------------------
def test_connect_raises_clean_error_when_websockets_missing(monkeypatch):
from plugins.google_meet.realtime.openai_client import RealtimeSession
# Make `import websockets.sync.client` fail.
monkeypatch.setitem(sys.modules, "websockets", None)
monkeypatch.setitem(sys.modules, "websockets.sync", None)
monkeypatch.setitem(sys.modules, "websockets.sync.client", None)
sess = RealtimeSession(api_key="sk-test")
with pytest.raises(RuntimeError, match="pip install websockets"):
sess.connect()
# ---------------------------------------------------------------------------
# RealtimeSpeaker
# ---------------------------------------------------------------------------
class _StubSession:
def __init__(self):
self.spoken: list[str] = []
def speak(self, text, timeout=30.0): # noqa: ARG002
self.spoken.append(text)
return {"ok": True, "bytes_written": len(text), "duration_ms": 1.0}
def test_speaker_run_until_stopped_processes_queue(tmp_path):
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
queue = tmp_path / "queue.jsonl"
processed = tmp_path / "processed.jsonl"
queue.write_text(
json.dumps({"id": "a", "text": "hello one"}) + "\n"
+ json.dumps({"id": "b", "text": "hello two"}) + "\n"
)
stub = _StubSession()
speaker = RealtimeSpeaker(stub, queue_path=queue, processed_path=processed)
# Stop once the queue is empty.
def _stop():
return queue.exists() and queue.read_text().strip() == ""
speaker.run_until_stopped(_stop, poll_interval=0.01)
assert stub.spoken == ["hello one", "hello two"]
# Processed file has both entries, in order.
lines = [json.loads(l) for l in processed.read_text().splitlines() if l.strip()]
assert [l["id"] for l in lines] == ["a", "b"]
assert all(l["result"]["ok"] for l in lines)
# Queue is empty (possibly empty string) after processing.
assert queue.read_text().strip() == ""
def test_speaker_exits_immediately_when_stop_fn_true(tmp_path):
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
queue = tmp_path / "q.jsonl"
queue.write_text(json.dumps({"id": "x", "text": "never spoken"}) + "\n")
stub = _StubSession()
speaker = RealtimeSpeaker(stub, queue_path=queue)
speaker.run_until_stopped(lambda: True, poll_interval=0.01)
assert stub.spoken == []
def test_speaker_drops_line_without_processed_path_when_none(tmp_path):
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
queue = tmp_path / "q.jsonl"
queue.write_text(json.dumps({"id": "only", "text": "once"}) + "\n")
stub = _StubSession()
speaker = RealtimeSpeaker(stub, queue_path=queue, processed_path=None)
def _stop():
return queue.read_text().strip() == ""
speaker.run_until_stopped(_stop, poll_interval=0.01)
assert stub.spoken == ["once"]
assert queue.read_text().strip() == ""
@@ -0,0 +1,64 @@
"""Embedded-daemon health grace timeout export (issue #13125 comment thread).
On resource-contended hosts the embedded Hindsight daemon can exceed a single
2s /health check and get needlessly killed + restarted. Upstream exposes the
grace window via HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT (read at import
time). The plugin surfaces it as a config.json knob and exports it to the
process env BEFORE daemon_embed_manager is imported.
"""
import importlib
import pytest
hindsight = importlib.import_module("plugins.memory.hindsight")
_export = hindsight._export_port_health_grace_timeout
_ENV = hindsight._PORT_HEALTH_GRACE_ENV
@pytest.fixture(autouse=True)
def _clear_env(monkeypatch):
monkeypatch.delenv(_ENV, raising=False)
def test_configured_value_exported(monkeypatch):
_export({"port_health_grace_timeout": 60})
import os
assert float(os.environ[_ENV]) == 60.0
def test_string_value_parsed(monkeypatch):
_export({"port_health_grace_timeout": "45"})
import os
assert float(os.environ[_ENV]) == 45.0
def test_blank_and_missing_are_noops(monkeypatch):
import os
_export({})
assert _ENV not in os.environ
_export({"port_health_grace_timeout": ""})
assert _ENV not in os.environ
_export({"port_health_grace_timeout": None})
assert _ENV not in os.environ
def test_invalid_and_negative_ignored(monkeypatch):
import os
_export({"port_health_grace_timeout": "not-a-number"})
assert _ENV not in os.environ
_export({"port_health_grace_timeout": -5})
assert _ENV not in os.environ
def test_explicit_env_wins_over_config(monkeypatch):
import os
monkeypatch.setenv(_ENV, "99")
_export({"port_health_grace_timeout": 60})
# setdefault must not clobber an operator-set env override.
assert os.environ[_ENV] == "99"
@@ -0,0 +1,94 @@
"""Root-user guard for Hindsight local_embedded mode (issue #13125).
PostgreSQL's initdb refuses to run as root, so the embedded Hindsight daemon
can never initialize under root — without a guard it crash-restart loops
forever, burning RAM/CPU with no user-visible error. initialize() must detect
root up front, skip daemon startup, disable the provider, and warn the user.
"""
import importlib
import threading
import pytest
hindsight = importlib.import_module("plugins.memory.hindsight")
HindsightMemoryProvider = hindsight.HindsightMemoryProvider
def _make_local_embedded_provider(monkeypatch):
"""Build a provider wired for local_embedded with a passing runtime probe."""
monkeypatch.setattr(
hindsight,
"_load_config",
lambda: {"mode": "local_embedded", "profile": "hermes"},
)
# Pretend the local runtime imports cleanly so initialize() reaches the
# daemon-start branch instead of bailing on a missing `hindsight` package.
monkeypatch.setattr(hindsight, "_check_local_runtime", lambda: (True, None))
return HindsightMemoryProvider()
def _daemon_threads_alive() -> list[str]:
return [t.name for t in threading.enumerate() if t.name == "hindsight-daemon-start"]
def test_local_embedded_skips_daemon_as_root(monkeypatch, caplog):
"""As root, the daemon thread must NOT start and the mode is disabled."""
provider = _make_local_embedded_provider(monkeypatch)
monkeypatch.setattr(hindsight.os, "geteuid", lambda: 0, raising=False)
# If the guard fails, _start_daemon would call _get_client() — make that
# explode so a regression is loud rather than silently spawning a thread.
monkeypatch.setattr(
provider,
"_get_client",
lambda: pytest.fail("daemon startup attempted while running as root"),
)
before = set(_daemon_threads_alive())
with caplog.at_level("WARNING", logger="plugins.memory.hindsight"):
provider.initialize(session_id="s1")
assert provider._mode == "disabled"
assert set(_daemon_threads_alive()) == before # no new daemon thread
# The warning is surfaced to the user via the logger AND printed to
# stderr (E2E-verified in tests/plugins/test_hindsight_root_guard.py
# docstring rationale); capsys can't reliably capture the module-level
# sys.stderr write under the isolation harness, so assert on the log.
assert any("cannot run as root" in r.message for r in caplog.records)
def test_local_embedded_starts_daemon_as_non_root(monkeypatch):
"""As a non-root user, the daemon-start thread IS spawned."""
provider = _make_local_embedded_provider(monkeypatch)
monkeypatch.setattr(hindsight.os, "geteuid", lambda: 1000, raising=False)
started = threading.Event()
monkeypatch.setattr(
hindsight.threading,
"Thread",
_fake_thread_factory(started),
)
provider.initialize(session_id="s1")
assert provider._mode == "local_embedded"
assert started.is_set()
def _fake_thread_factory(started: threading.Event):
"""Return a Thread replacement that records start() without running work."""
real_thread = threading.Thread
def _factory(*args, **kwargs):
if kwargs.get("name") == "hindsight-daemon-start":
started.set()
class _NoopThread:
def start(self):
pass
return _NoopThread()
return real_thread(*args, **kwargs)
return _factory
+291
View File
@@ -0,0 +1,291 @@
"""Tests for Kanban task file attachments (#35338).
Covers three layers:
* ``hermes_cli.kanban_db`` accessors (add/list/get/delete + path helpers)
* the dashboard REST surface (upload / list / download / delete)
* worker-context surfacing so a kanban worker sees the absolute paths
The plugin router is attached to a bare FastAPI app — same approach as
``test_kanban_dashboard_plugin.py`` — so we exercise the real HTTP path
(multipart upload, streaming download) without the whole dashboard.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
spec = importlib.util.spec_from_file_location(
"hermes_dashboard_plugin_kanban_attach_test", plugin_file,
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def _make_task(conn, title="t") -> str:
return kb.create_task(conn, title=title)
# ---------------------------------------------------------------------------
# DB-layer accessors
# ---------------------------------------------------------------------------
def test_add_list_get_delete_attachment(kanban_home, tmp_path):
conn = kb.connect()
try:
task_id = _make_task(conn)
# Write a real blob under the per-task dir so delete can unlink it.
dest_dir = kb.task_attachments_dir(task_id)
dest_dir.mkdir(parents=True, exist_ok=True)
blob = dest_dir / "source.pdf"
blob.write_bytes(b"%PDF-1.4 fake")
att_id = kb.add_attachment(
conn,
task_id,
filename="source.pdf",
stored_path=str(blob),
content_type="application/pdf",
size=blob.stat().st_size,
uploaded_by="tester",
)
assert att_id > 0
atts = kb.list_attachments(conn, task_id)
assert len(atts) == 1
a = atts[0]
assert a.filename == "source.pdf"
assert a.content_type == "application/pdf"
assert a.size == len(b"%PDF-1.4 fake")
assert a.uploaded_by == "tester"
assert a.stored_path == str(blob)
got = kb.get_attachment(conn, att_id)
assert got is not None and got.id == att_id
removed = kb.delete_attachment(conn, att_id)
assert removed is not None and removed.id == att_id
assert kb.list_attachments(conn, task_id) == []
assert not blob.exists(), "delete should unlink the on-disk blob"
assert kb.get_attachment(conn, att_id) is None
finally:
conn.close()
def test_add_attachment_rejects_unknown_task(kanban_home):
conn = kb.connect()
try:
with pytest.raises(ValueError):
kb.add_attachment(
conn, "t_doesnotexist", filename="x.txt", stored_path="/tmp/x.txt"
)
finally:
conn.close()
def test_add_attachment_appends_event(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn)
kb.add_attachment(
conn, task_id, filename="a.txt", stored_path="/tmp/a.txt", size=3
)
kinds = [e.kind for e in kb.list_events(conn, task_id)]
assert "attached" in kinds
finally:
conn.close()
def test_delete_attachment_missing_returns_none(kanban_home):
conn = kb.connect()
try:
assert kb.delete_attachment(conn, 999999) is None
finally:
conn.close()
def test_attachments_root_is_per_board(kanban_home, monkeypatch):
# default board uses <root>/kanban/attachments
default_root = kb.attachments_root(board="default")
assert default_root.name == "attachments"
# a named board nests under its board dir
monkeypatch.delenv("HERMES_KANBAN_ATTACHMENTS_ROOT", raising=False)
named = kb.attachments_root(board="default")
assert named == default_root
def test_attachments_root_env_override(kanban_home, monkeypatch, tmp_path):
override = tmp_path / "custom-attach"
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(override))
assert kb.attachments_root() == override
assert kb.task_attachments_dir("t_abc") == override / "t_abc"
# ---------------------------------------------------------------------------
# Worker context surfacing
# ---------------------------------------------------------------------------
def test_worker_context_lists_attachments_with_absolute_path(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn, title="translate PDF")
dest_dir = kb.task_attachments_dir(task_id)
dest_dir.mkdir(parents=True, exist_ok=True)
blob = dest_dir / "manual.pdf"
blob.write_bytes(b"data")
kb.add_attachment(
conn,
task_id,
filename="manual.pdf",
stored_path=str(blob.resolve()),
content_type="application/pdf",
size=4,
)
ctx = kb.build_worker_context(conn, task_id)
assert "## Attachments" in ctx
assert "manual.pdf" in ctx
# The absolute path must appear so the worker can read_file it.
assert str(blob.resolve()) in ctx
finally:
conn.close()
def test_worker_context_no_attachments_section_when_empty(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn)
ctx = kb.build_worker_context(conn, task_id)
assert "## Attachments" not in ctx
finally:
conn.close()
# ---------------------------------------------------------------------------
# REST surface — upload / list / download / delete round-trip
# ---------------------------------------------------------------------------
def _create_task_via_api(client) -> str:
r = client.post("/api/plugins/kanban/tasks", json={"title": "x"})
assert r.status_code == 200, r.text
return r.json()["task"]["id"]
def test_upload_list_download_delete_roundtrip(client):
task_id = _create_task_via_api(client)
content = b"hello attachment world"
# Upload
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("notes.txt", content, "text/plain")},
)
assert r.status_code == 200, r.text
att = r.json()["attachment"]
assert att["filename"] == "notes.txt"
assert att["size"] == len(content)
att_id = att["id"]
# List (drawer also embeds it in GET /tasks/:id)
r = client.get(f"/api/plugins/kanban/tasks/{task_id}/attachments")
assert r.status_code == 200
assert [a["filename"] for a in r.json()["attachments"]] == ["notes.txt"]
detail = client.get(f"/api/plugins/kanban/tasks/{task_id}").json()
assert "attachments" in detail
assert len(detail["attachments"]) == 1
# Download streams the exact bytes back
r = client.get(f"/api/plugins/kanban/attachments/{att_id}")
assert r.status_code == 200
assert r.content == content
# Delete removes the row and the file
r = client.delete(f"/api/plugins/kanban/attachments/{att_id}")
assert r.status_code == 200
assert client.get(f"/api/plugins/kanban/attachments/{att_id}").status_code == 404
assert client.get(
f"/api/plugins/kanban/tasks/{task_id}/attachments"
).json()["attachments"] == []
def test_upload_sanitizes_traversal_filename(client):
task_id = _create_task_via_api(client)
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("../../../../etc/passwd", b"x", "text/plain")},
)
assert r.status_code == 200, r.text
stored_path = r.json()["attachment"]["stored_path"]
# The leaf name only; never escapes the per-task attachments dir.
assert Path(stored_path).name == "passwd"
task_dir = kb.task_attachments_dir(task_id).resolve()
assert Path(stored_path).resolve().is_relative_to(task_dir)
def test_upload_name_collision_gets_suffixed(client):
task_id = _create_task_via_api(client)
for _ in range(2):
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("dup.txt", b"a", "text/plain")},
)
assert r.status_code == 200, r.text
names = sorted(
a["filename"]
for a in client.get(
f"/api/plugins/kanban/tasks/{task_id}/attachments"
).json()["attachments"]
)
assert names == ["dup (1).txt", "dup.txt"]
def test_upload_unknown_task_404(client):
r = client.post(
"/api/plugins/kanban/tasks/t_nope/attachments",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert r.status_code == 404
def test_download_unknown_attachment_404(client):
assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404
File diff suppressed because it is too large Load Diff
+440
View File
@@ -0,0 +1,440 @@
"""Tests for kanban worker/runs read endpoints.
Covers:
GET /workers/active
GET /runs/{run_id}
GET /runs/{run_id}/inspect
POST /runs/{run_id}/terminate
"""
from __future__ import annotations
import importlib.util
import secrets
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _load_plugin_router():
"""Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router."""
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
mod_name = "hermes_dashboard_plugin_kanban_worker_runs_test"
# Re-use a cached module if already loaded to avoid duplicate-router issues.
if mod_name in sys.modules:
return sys.modules[mod_name].router
spec = importlib.util.spec_from_file_location(mod_name, plugin_file)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with an empty kanban DB."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def _insert_run(conn, task_id, *, worker_pid=None, ended_at=None):
"""Insert a task_runs row directly (bypassing claim machinery) and return run_id."""
lock = secrets.token_hex(8)
future = int(time.time()) + 3600
cur = conn.execute(
"INSERT INTO task_runs "
"(task_id, status, claim_lock, claim_expires, worker_pid, started_at, ended_at) "
"VALUES (?, 'running', ?, ?, ?, ?, ?)",
(task_id, lock, future, worker_pid, int(time.time()), ended_at),
)
conn.commit()
return cur.lastrowid
# ---------------------------------------------------------------------------
# GET /workers/active
# ---------------------------------------------------------------------------
def test_workers_active_empty_board(client):
"""Board with no running tasks returns an empty workers list."""
r = client.get("/api/plugins/kanban/workers/active")
assert r.status_code == 200
body = r.json()
assert body["workers"] == []
assert body["count"] == 0
assert "checked_at" in body
def test_workers_active_with_running_task(client):
"""A running task with an open run row and worker_pid appears in the list."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="active-worker", assignee="alice")
conn.execute(
"UPDATE tasks SET status='running' WHERE id=?", (task_id,),
)
_insert_run(conn, task_id, worker_pid=12345)
finally:
conn.close()
r = client.get("/api/plugins/kanban/workers/active")
assert r.status_code == 200
body = r.json()
assert body["count"] == 1
w = body["workers"][0]
assert w["task_id"] == task_id
assert w["worker_pid"] == 12345
assert w["task_status"] == "running"
assert w["task_title"] == "active-worker"
assert w["task_assignee"] == "alice"
def test_workers_active_excludes_ended_runs(client):
"""Runs with ended_at set are excluded even if task is running."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="ended-run", assignee="bob")
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (task_id,))
_insert_run(conn, task_id, worker_pid=99999, ended_at=int(time.time()) - 60)
finally:
conn.close()
r = client.get("/api/plugins/kanban/workers/active")
assert r.status_code == 200
assert r.json()["count"] == 0
def test_workers_active_excludes_runs_without_pid(client):
"""Runs with no worker_pid are not considered active workers."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="no-pid", assignee="carol")
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (task_id,))
_insert_run(conn, task_id, worker_pid=None)
finally:
conn.close()
r = client.get("/api/plugins/kanban/workers/active")
assert r.status_code == 200
assert r.json()["count"] == 0
# ---------------------------------------------------------------------------
# GET /runs/{run_id}
# ---------------------------------------------------------------------------
def test_get_run_404_unknown_id(client):
"""Non-existent run_id returns 404."""
r = client.get("/api/plugins/kanban/runs/999999")
assert r.status_code == 404
assert "999999" in r.json()["detail"]
def test_get_run_ok(client):
"""Existing run row returns 200 with expected shape."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="run-lookup", assignee="dave")
run_id = _insert_run(conn, task_id, worker_pid=55555)
finally:
conn.close()
r = client.get(f"/api/plugins/kanban/runs/{run_id}")
assert r.status_code == 200
body = r.json()
assert "run" in body
run = body["run"]
assert run["id"] == run_id
assert run["task_id"] == task_id
assert run["worker_pid"] == 55555
assert run["ended_at"] is None
# ---------------------------------------------------------------------------
# GET /runs/{run_id}/inspect
# ---------------------------------------------------------------------------
def test_inspect_run_404(client):
"""Non-existent run_id returns 404."""
r = client.get("/api/plugins/kanban/runs/888888/inspect")
assert r.status_code == 404
def test_inspect_run_already_ended(client):
"""Run with ended_at set returns alive=false with reason."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="ended", assignee="eve")
run_id = _insert_run(conn, task_id, worker_pid=11111, ended_at=int(time.time()) - 10)
finally:
conn.close()
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
assert r.status_code == 200
body = r.json()
assert body["alive"] is False
assert "ended" in body["reason"]
def test_inspect_run_no_pid(client):
"""Run with no worker_pid returns alive=false with reason."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="no-pid-inspect", assignee="frank")
run_id = _insert_run(conn, task_id, worker_pid=None)
finally:
conn.close()
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
assert r.status_code == 200
body = r.json()
assert body["alive"] is False
assert "worker_pid" in body["reason"]
def test_inspect_run_dead_pid(client, monkeypatch):
"""Run with a non-existent PID returns alive=false via psutil.NoSuchProcess."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="dead-pid", assignee="grace")
run_id = _insert_run(conn, task_id, worker_pid=999999)
finally:
conn.close()
# Mock psutil to raise NoSuchProcess for any PID.
mock_psutil = MagicMock()
mock_psutil.NoSuchProcess = Exception
mock_psutil.AccessDenied = PermissionError
def _raise_no_such(*args, **kwargs):
raise mock_psutil.NoSuchProcess("no such process")
mock_psutil.Process = _raise_no_such
# Patch the module-level _psutil in the loaded plugin module.
plugin_mod_name = "hermes_dashboard_plugin_kanban_worker_runs_test"
plugin_mod = sys.modules.get(plugin_mod_name)
if plugin_mod is not None:
monkeypatch.setattr(plugin_mod, "_psutil", mock_psutil)
else:
pytest.skip("plugin module not yet loaded")
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
assert r.status_code == 200
body = r.json()
assert body["alive"] is False
assert body["pid"] == 999999
assert "not found" in body["reason"]
def test_inspect_run_live_pid(client, monkeypatch):
"""Run with a live PID returns alive=true with psutil fields."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="live-pid", assignee="heidi")
run_id = _insert_run(conn, task_id, worker_pid=12345)
finally:
conn.close()
# Build a realistic mock psutil.
mock_psutil = MagicMock()
mock_psutil.NoSuchProcess = type("NoSuchProcess", (Exception,), {})
mock_psutil.AccessDenied = type("AccessDenied", (Exception,), {})
fake_mem = MagicMock()
fake_mem.rss = 1024 * 1024 * 50 # 50 MB
fake_mem.vms = 1024 * 1024 * 200
fake_proc = MagicMock()
fake_proc.as_dict.return_value = {
"cpu_percent": 3.5,
"memory_info": fake_mem,
"num_threads": 4,
"status": "sleeping",
"create_time": time.time() - 300,
"cmdline": ["python", "-m", "hermes"],
}
fake_proc.num_fds.return_value = 12
mock_psutil.Process.return_value = fake_proc
plugin_mod_name = "hermes_dashboard_plugin_kanban_worker_runs_test"
plugin_mod = sys.modules.get(plugin_mod_name)
if plugin_mod is not None:
monkeypatch.setattr(plugin_mod, "_psutil", mock_psutil)
else:
pytest.skip("plugin module not yet loaded")
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
assert r.status_code == 200
body = r.json()
assert body["alive"] is True
assert body["pid"] == 12345
assert body["cpu_percent"] == 3.5
assert body["memory_rss_bytes"] == fake_mem.rss
assert body["num_threads"] == 4
assert body["status"] == "sleeping"
# ---------------------------------------------------------------------------
# POST /runs/{run_id}/terminate
# ---------------------------------------------------------------------------
def _setup_running_task_with_run(conn, *, title, assignee, worker_pid):
"""Create a task in 'running' state with a matching open task_runs row.
Mirrors what dispatcher_claim does: stamps tasks.status='running',
tasks.claim_lock, tasks.worker_pid; inserts task_runs row with the
same claim_lock so reclaim_task's preconditions are satisfied.
"""
task_id = kb.create_task(conn, title=title, assignee=assignee)
lock = secrets.token_hex(8)
future = int(time.time()) + 3600
conn.execute(
"UPDATE tasks SET status='running', claim_lock=?, "
"claim_expires=?, worker_pid=? WHERE id=?",
(lock, future, worker_pid, task_id),
)
cur = conn.execute(
"INSERT INTO task_runs "
"(task_id, status, claim_lock, claim_expires, worker_pid, started_at) "
"VALUES (?, 'running', ?, ?, ?, ?)",
(task_id, lock, future, worker_pid, int(time.time())),
)
conn.commit()
return task_id, cur.lastrowid
def test_terminate_run_404_unknown_id(client):
"""POST to unknown run_id returns 404."""
r = client.post(
"/api/plugins/kanban/runs/777777/terminate",
json={"reason": "test"},
)
assert r.status_code == 404
assert "777777" in r.json()["detail"]
def test_terminate_run_409_already_ended(client):
"""POST against a run with ended_at set returns 409."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="ended-terminate", assignee="ivy")
run_id = _insert_run(
conn, task_id, worker_pid=22222, ended_at=int(time.time()) - 30,
)
finally:
conn.close()
r = client.post(
f"/api/plugins/kanban/runs/{run_id}/terminate",
json={"reason": "too late"},
)
assert r.status_code == 409
assert "already ended" in r.json()["detail"]
def test_terminate_run_ok(client, monkeypatch):
"""Happy path: live run is terminated, signal fn invoked, reason recorded."""
conn = kb.connect()
try:
task_id, run_id = _setup_running_task_with_run(
conn, title="kill-me", assignee="jane", worker_pid=33333,
)
finally:
conn.close()
# Capture signal calls so we don't actually SIGTERM a random PID.
sent = []
def _fake_terminate(pid, prev_lock, *, signal_fn=None):
sent.append((pid, prev_lock))
return {"signal": "SIGTERM", "delivered": True}
monkeypatch.setattr(kb, "_terminate_reclaimed_worker", _fake_terminate)
r = client.post(
f"/api/plugins/kanban/runs/{run_id}/terminate",
json={"reason": "operator abort"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body == {"ok": True, "run_id": run_id, "task_id": task_id}
assert sent == [(33333, sent[0][1])]
assert sent[0][1] is not None # claim_lock was non-null
# Task is back to ready, claim cleared.
conn = kb.connect()
try:
row = conn.execute(
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?",
(task_id,),
).fetchone()
finally:
conn.close()
assert row["status"] == "ready"
assert row["claim_lock"] is None
assert row["worker_pid"] is None
def test_terminate_run_409_task_not_reclaimable(client, monkeypatch):
"""Open run row whose task is no longer claimable returns 409."""
conn = kb.connect()
try:
task_id = kb.create_task(conn, title="ghost-run", assignee="ken")
# Task left in default 'ready' state with no claim_lock — task_run
# exists but reclaim_task will refuse because status != 'running'
# and claim_lock is NULL.
run_id = _insert_run(conn, task_id, worker_pid=44444)
finally:
conn.close()
# Make sure no signal is ever sent on this code path.
def _boom(*a, **k):
raise AssertionError("_terminate_reclaimed_worker should not be called")
monkeypatch.setattr(kb, "_terminate_reclaimed_worker", _boom)
r = client.post(
f"/api/plugins/kanban/runs/{run_id}/terminate",
json={"reason": "stale"},
)
assert r.status_code == 409
assert "reclaimable" in r.json()["detail"]
def test_terminate_run_accepts_empty_body(client):
"""Empty JSON body (no reason) is still accepted; falls through to 404."""
r = client.post(
"/api/plugins/kanban/runs/666666/terminate",
json={},
)
# 404 because run doesn't exist — what we're asserting here is that
# the endpoint doesn't 422 on a missing 'reason' field.
assert r.status_code == 404
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
"""Guardrail: dashboard plugins must NOT read the session token directly.
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
(``hermes_session_at`` cookie / single-use ``?ticket=``).
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
and 401/1008. That bug shipped in the kanban and achievements plugins and was
invisible until the dashboard ran gated on hosted Fly agents.
This test fails if any bundled plugin's frontend reads the token global
directly, forcing new/edited plugins through the SDK surface instead. It is
the enforcement half of the "single sanctioned auth surface" design — the SDK
helpers are the carrot, this test is the stick.
If you have a legitimate reason to reference the token name (e.g. a comment
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
# Repo root: tests/plugins/<this file> → ../../
_REPO_ROOT = Path(__file__).resolve().parents[2]
_PLUGINS_DIR = _REPO_ROOT / "plugins"
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
# Files explicitly allowed to mention the token (none today). Map path →
# reason so the allowance is self-documenting if one is ever needed.
_ALLOWED_FILES: dict[str, str] = {}
def _plugin_frontend_bundles() -> list[Path]:
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
if not _PLUGINS_DIR.is_dir():
return []
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
def test_there_are_plugin_bundles_to_check() -> None:
"""Sanity: the glob actually finds the bundles, so a future layout change
doesn't silently turn this guard into a no-op."""
bundles = _plugin_frontend_bundles()
names = {b.parent.parent.parent.name for b in bundles}
# kanban + hermes-achievements are bundled today; assert at least one is
# found so the guard can't pass vacuously.
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
assert names, "could not resolve plugin names from bundle paths"
@pytest.mark.parametrize(
"bundle",
_plugin_frontend_bundles(),
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
)
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
rel = str(bundle.relative_to(_REPO_ROOT))
text = bundle.read_text(encoding="utf-8", errors="replace")
if rel in _ALLOWED_FILES:
return # explicitly allowed (with a documented reason)
# Only flag CODE reads of the token global, not mentions in ``//`` comments
# (e.g. a comment explaining why the SDK helper is used instead). A line is
# a code read if it contains the global and the global appears before any
# ``//`` comment marker on that line.
offending: list[str] = []
for i, line in enumerate(text.splitlines(), start=1):
idx = line.find(_FORBIDDEN)
if idx == -1:
continue
comment_idx = line.find("//")
in_comment = comment_idx != -1 and comment_idx < idx
if not in_comment:
offending.append(f" {i}: {line.strip()}")
if not offending:
return
pytest.fail(
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
)
@@ -0,0 +1,75 @@
"""Regression tests for the raft platform plugin's check_fn.
The raft platform adapter's ``check_raft_requirements()`` is registered as
the platform's ``check_fn``. This function is invoked on every
``load_gateway_config()`` call (dozens of times during normal gateway
operation). It must therefore be a *silent* predicate — returning True/False
without logging — otherwise every user without the ``raft`` CLI installed
gets their logs flooded with WARNING messages every few seconds.
See: https://github.com/NousResearch/hermes-agent/issues/49234
"""
import logging
from unittest.mock import patch
import pytest
@pytest.fixture
def raft_check():
"""Import check_raft_requirements fresh (adapter self-manages sys.path)."""
from plugins.platforms.raft.adapter import check_raft_requirements
return check_raft_requirements
def test_check_returns_false_when_raft_cli_missing(raft_check):
"""check_fn returns False when raft CLI is not in PATH."""
with patch("plugins.platforms.raft.adapter.shutil.which", return_value=None), \
patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", True):
assert raft_check() is False
def test_check_returns_false_when_aiohttp_missing(raft_check):
"""check_fn returns False when aiohttp dependency is unavailable."""
with patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", False):
assert raft_check() is False
def test_check_returns_true_when_all_deps_present(raft_check):
"""check_fn returns True when all dependencies are available."""
with patch("plugins.platforms.raft.adapter.shutil.which", return_value="/usr/bin/raft"), \
patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", True):
assert raft_check() is True
def test_check_silent_when_raft_cli_missing(raft_check, caplog):
"""check_fn must NOT log a WARNING when raft CLI is missing.
This is the regression guard for issue #49234 — logging inside check_fn
causes log spam because the function is called on every config load.
"""
with patch("plugins.platforms.raft.adapter.shutil.which", return_value=None), \
patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", True):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.raft.adapter"):
raft_check()
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warnings == [], (
f"check_raft_requirements must be silent (no WARNING logs), "
f"but emitted: {[r.getMessage() for r in warnings]}"
)
def test_check_silent_when_aiohttp_missing(raft_check, caplog):
"""check_fn must NOT log a WARNING when aiohttp is missing."""
with patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", False):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.raft.adapter"):
raft_check()
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warnings == [], (
f"check_raft_requirements must be silent (no WARNING logs), "
f"but emitted: {[r.getMessage() for r in warnings]}"
)
+738
View File
@@ -0,0 +1,738 @@
"""Tests for the RetainDB memory plugin.
Covers: _Client HTTP client, _WriteQueue SQLite queue, _build_overlay formatter,
RetainDBMemoryProvider lifecycle/tools/prefetch, thread management, connection pooling.
"""
import json
import sqlite3
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Imports — guarded since plugins/memory lives outside the standard test path
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_env(tmp_path, monkeypatch):
"""Ensure HERMES_HOME and RETAINDB vars are isolated."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("RETAINDB_API_KEY", raising=False)
monkeypatch.delenv("RETAINDB_BASE_URL", raising=False)
monkeypatch.delenv("RETAINDB_PROJECT", raising=False)
@pytest.fixture(autouse=True)
def _cap_retaindb_sleeps(monkeypatch):
"""Cap production-code sleeps so background-thread tests run fast.
The retaindb ``_WriteQueue._flush_row`` does ``time.sleep(2)`` after
errors. Across multiple tests that trigger the retry path, that adds
up. Cap the module's bound ``time.sleep`` to 0.05s — tests don't care
about the exact retry delay, only that it happens. The test file's
own ``time.sleep`` stays real since it uses a different reference.
"""
try:
from plugins.memory import retaindb as _retaindb
except ImportError:
return
real_sleep = _retaindb.time.sleep
def _capped_sleep(seconds):
return real_sleep(min(float(seconds), 0.05))
import types as _types
fake_time = _types.SimpleNamespace(sleep=_capped_sleep, time=_retaindb.time.time)
monkeypatch.setattr(_retaindb, "time", fake_time)
# We need the repo root on sys.path so the plugin can import agent.memory_provider
import sys
_repo_root = str(Path(__file__).resolve().parents[2])
if _repo_root not in sys.path:
sys.path.insert(0, _repo_root)
from plugins.memory.retaindb import (
_Client,
_WriteQueue,
_build_overlay,
RetainDBMemoryProvider,
)
# ===========================================================================
# _Client tests
# ===========================================================================
class TestClient:
"""Test the HTTP client with mocked requests."""
def _make_client(self, api_key="rdb-test-key", base_url="https://api.retaindb.com", project="test"):
return _Client(api_key, base_url, project)
def test_base_url_trailing_slash_stripped(self):
c = self._make_client(base_url="https://api.retaindb.com///")
assert c.base_url == "https://api.retaindb.com"
def test_headers_include_auth(self):
c = self._make_client()
h = c._headers("/v1/files")
assert h["Authorization"] == "Bearer rdb-test-key"
assert "X-API-Key" not in h
def test_headers_include_api_key_for_memory_path(self):
c = self._make_client()
h = c._headers("/v1/memory/search")
assert h["X-API-Key"] == "rdb-test-key"
def test_headers_include_api_key_for_context_path(self):
c = self._make_client()
h = c._headers("/v1/context/query")
assert h["X-API-Key"] == "rdb-test-key"
def test_headers_strip_bearer_prefix(self):
c = self._make_client(api_key="Bearer rdb-test-key")
h = c._headers("/v1/memory/search")
assert h["Authorization"] == "Bearer rdb-test-key"
assert h["X-API-Key"] == "rdb-test-key"
def test_add_memory_tries_fallback(self):
c = self._make_client()
call_count = 0
def fake_request(method, path, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("404")
return {"id": "mem-1"}
with patch.object(c, "request", side_effect=fake_request):
result = c.add_memory("u1", "s1", "test fact")
assert result == {"id": "mem-1"}
assert call_count == 2
def test_delete_memory_tries_fallback(self):
c = self._make_client()
call_count = 0
def fake_request(method, path, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("404")
return {"deleted": True}
with patch.object(c, "request", side_effect=fake_request):
result = c.delete_memory("mem-123")
assert result == {"deleted": True}
assert call_count == 2
# ===========================================================================
# _WriteQueue tests
# ===========================================================================
class TestWriteQueue:
"""Test the SQLite-backed write queue with real SQLite."""
def _make_queue(self, tmp_path, client=None):
if client is None:
client = MagicMock()
client.ingest_session = MagicMock(return_value={"status": "ok"})
db_path = tmp_path / "test_queue.db"
return _WriteQueue(client, db_path), client, db_path
def test_enqueue_creates_row(self, tmp_path):
q, client, db_path = self._make_queue(tmp_path)
q.enqueue("user1", "sess1", [{"role": "user", "content": "hi"}])
# shutdown() blocks until the writer thread drains the queue — no need
# to pre-sleep (the old 1s sleep was a just-in-case wait, but shutdown
# does the right thing).
q.shutdown()
# If ingest succeeded, the row should be deleted
client.ingest_session.assert_called_once()
def test_enqueue_persists_to_sqlite(self, tmp_path):
client = MagicMock()
# Make ingest slow so the row is still in SQLite when we peek.
# 0.5s is plenty — the test just needs the flush to still be in-flight.
client.ingest_session = MagicMock(side_effect=lambda *a, **kw: time.sleep(0.5))
db_path = tmp_path / "test_queue.db"
q = _WriteQueue(client, db_path)
q.enqueue("user1", "sess1", [{"role": "user", "content": "test"}])
# Check SQLite directly — row should exist since flush is slow
conn = sqlite3.connect(str(db_path))
rows = conn.execute("SELECT user_id, session_id FROM pending").fetchall()
conn.close()
assert len(rows) >= 1
assert rows[0][0] == "user1"
q.shutdown()
def test_flush_deletes_row_on_success(self, tmp_path):
q, client, db_path = self._make_queue(tmp_path)
q.enqueue("user1", "sess1", [{"role": "user", "content": "hi"}])
q.shutdown() # blocks until drain
# Row should be gone
conn = sqlite3.connect(str(db_path))
rows = conn.execute("SELECT COUNT(*) FROM pending").fetchone()[0]
conn.close()
assert rows == 0
def test_flush_records_error_on_failure(self, tmp_path):
client = MagicMock()
client.ingest_session = MagicMock(side_effect=RuntimeError("API down"))
db_path = tmp_path / "test_queue.db"
q = _WriteQueue(client, db_path)
q.enqueue("user1", "sess1", [{"role": "user", "content": "hi"}])
# Poll for the error to be recorded (max 2s), instead of a fixed 3s wait.
deadline = time.time() + 2.0
last_error = None
while time.time() < deadline:
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT last_error FROM pending").fetchone()
conn.close()
if row and row[0]:
last_error = row[0]
break
time.sleep(0.05)
q.shutdown()
assert last_error is not None
assert "API down" in last_error
def test_thread_local_connection_reuse(self, tmp_path):
q, _, _ = self._make_queue(tmp_path)
# Same thread should get same connection
conn1 = q._get_conn()
conn2 = q._get_conn()
assert conn1 is conn2
q.shutdown()
def test_crash_recovery_replays_pending(self, tmp_path):
"""Simulate crash: create rows, then new queue should replay them."""
db_path = tmp_path / "recovery_test.db"
# First: create a queue and insert rows, but don't let them flush
client1 = MagicMock()
client1.ingest_session = MagicMock(side_effect=RuntimeError("fail"))
q1 = _WriteQueue(client1, db_path)
q1.enqueue("user1", "sess1", [{"role": "user", "content": "lost turn"}])
# Wait until the error is recorded (poll with short interval).
deadline = time.time() + 2.0
while time.time() < deadline:
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT last_error FROM pending").fetchone()
conn.close()
if row and row[0]:
break
time.sleep(0.05)
q1.shutdown()
# Now create a new queue — it should replay the pending rows
client2 = MagicMock()
client2.ingest_session = MagicMock(return_value={"status": "ok"})
q2 = _WriteQueue(client2, db_path)
# Poll for the replay to happen.
deadline = time.time() + 2.0
while time.time() < deadline:
if client2.ingest_session.called:
break
time.sleep(0.05)
q2.shutdown()
# The replayed row should have been ingested via client2
client2.ingest_session.assert_called_once()
call_args = client2.ingest_session.call_args
assert call_args[0][0] == "user1" # user_id
# ===========================================================================
# _build_overlay tests
# ===========================================================================
class TestBuildOverlay:
"""Test the overlay formatter (pure function)."""
def test_empty_inputs_returns_empty(self):
assert _build_overlay({}, {}) == ""
def test_empty_memories_returns_empty(self):
assert _build_overlay({"memories": []}, {"results": []}) == ""
def test_profile_items_included(self):
profile = {"memories": [{"content": "User likes Python"}]}
result = _build_overlay(profile, {})
assert "User likes Python" in result
assert "[RetainDB Context]" in result
def test_query_results_included(self):
query_result = {"results": [{"content": "Previous discussion about Rust"}]}
result = _build_overlay({}, query_result)
assert "Previous discussion about Rust" in result
def test_deduplication_removes_duplicates(self):
profile = {"memories": [{"content": "User likes Python"}]}
query_result = {"results": [{"content": "User likes Python"}]}
result = _build_overlay(profile, query_result)
assert result.count("User likes Python") == 1
def test_local_entries_filter(self):
profile = {"memories": [{"content": "Already known fact"}]}
result = _build_overlay(profile, {}, local_entries=["Already known fact"])
# The profile item matches a local entry, should be filtered
assert result == ""
def test_max_five_items_per_section(self):
profile = {"memories": [{"content": f"Fact {i}"} for i in range(10)]}
result = _build_overlay(profile, {})
# Should only include first 5
assert "Fact 0" in result
assert "Fact 4" in result
assert "Fact 5" not in result
def test_none_content_handled(self):
profile = {"memories": [{"content": None}, {"content": "Real fact"}]}
result = _build_overlay(profile, {})
assert "Real fact" in result
def test_truncation_at_320_chars(self):
long_content = "x" * 500
profile = {"memories": [{"content": long_content}]}
result = _build_overlay(profile, {})
# Each item is compacted to 320 chars max
for line in result.split("\n"):
if line.startswith("- "):
assert len(line) <= 322 # "- " + 320
# ===========================================================================
# RetainDBMemoryProvider tests
# ===========================================================================
class TestRetainDBMemoryProvider:
"""Test the main plugin class."""
def _make_provider(self, tmp_path, monkeypatch, api_key="rdb-test-key"):
monkeypatch.setenv("RETAINDB_API_KEY", api_key)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir(exist_ok=True)
provider = RetainDBMemoryProvider()
return provider
def test_name(self):
p = RetainDBMemoryProvider()
assert p.name == "retaindb"
def test_is_available_without_key(self):
p = RetainDBMemoryProvider()
assert p.is_available() is False
def test_is_available_with_key(self, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test")
p = RetainDBMemoryProvider()
assert p.is_available() is True
def test_config_schema(self):
p = RetainDBMemoryProvider()
schema = p.get_config_schema()
assert len(schema) == 3
keys = [s["key"] for s in schema]
assert "api_key" in keys
assert "base_url" in keys
assert "project" in keys
def test_initialize_creates_client_and_queue(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
assert p._client is not None
assert p._queue is not None
assert p._session_id == "test-session"
p.shutdown()
def test_initialize_default_project(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
assert p._client.project == "default"
p.shutdown()
def test_initialize_explicit_project(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_PROJECT", "my-project")
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
assert p._client.project == "my-project"
p.shutdown()
def test_initialize_profile_project(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
profile_home = str(tmp_path / "profiles" / "coder")
p.initialize("test-session", hermes_home=profile_home)
assert p._client.project == "hermes-coder"
p.shutdown()
def test_initialize_seeds_soul_md(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
soul_path = tmp_path / ".hermes" / "SOUL.md"
soul_path.write_text("I am a helpful agent.")
with patch.object(RetainDBMemoryProvider, "_seed_soul") as mock_seed:
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
# Give thread time to start
time.sleep(0.5)
mock_seed.assert_called_once_with("I am a helpful agent.")
p.shutdown()
def test_system_prompt_block(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
block = p.system_prompt_block()
assert "RetainDB Memory" in block
assert "Active" in block
p.shutdown()
def test_handle_tool_call_not_initialized(self):
p = RetainDBMemoryProvider()
result = json.loads(p.handle_tool_call("retaindb_profile", {}))
assert "error" in result
assert "not initialized" in result["error"]
def test_handle_tool_call_unknown_tool(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_nonexistent", {}))
assert result == {"error": "Unknown tool: retaindb_nonexistent"}
p.shutdown()
def test_dispatch_profile(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "get_profile", return_value={"memories": []}):
result = json.loads(p.handle_tool_call("retaindb_profile", {}))
assert "memories" in result
p.shutdown()
def test_dispatch_search_requires_query(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_search", {}))
assert result == {"error": "query is required"}
p.shutdown()
def test_dispatch_search(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "search", return_value={"results": [{"content": "found"}]}):
result = json.loads(p.handle_tool_call("retaindb_search", {"query": "test"}))
assert "results" in result
p.shutdown()
def test_dispatch_search_top_k_capped(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "search") as mock_search:
mock_search.return_value = {"results": []}
p.handle_tool_call("retaindb_search", {"query": "test", "top_k": 100})
# top_k should be capped at 20
assert mock_search.call_args[1]["top_k"] == 20
p.shutdown()
def test_dispatch_remember(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "add_memory", return_value={"id": "mem-1"}):
result = json.loads(p.handle_tool_call("retaindb_remember", {"content": "test fact"}))
assert result["id"] == "mem-1"
p.shutdown()
def test_dispatch_remember_requires_content(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_remember", {}))
assert result == {"error": "content is required"}
p.shutdown()
def test_dispatch_forget(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "delete_memory", return_value={"deleted": True}):
result = json.loads(p.handle_tool_call("retaindb_forget", {"memory_id": "mem-1"}))
assert result["deleted"] is True
p.shutdown()
def test_dispatch_forget_requires_id(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_forget", {}))
assert result == {"error": "memory_id is required"}
p.shutdown()
def test_dispatch_context(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "query_context", return_value={"results": [{"content": "relevant"}]}), \
patch.object(p._client, "get_profile", return_value={"memories": []}):
result = json.loads(p.handle_tool_call("retaindb_context", {"query": "current task"}))
assert "context" in result
assert "raw" in result
p.shutdown()
def test_dispatch_file_list(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "list_files", return_value={"files": []}):
result = json.loads(p.handle_tool_call("retaindb_list_files", {}))
assert "files" in result
p.shutdown()
def test_dispatch_file_upload_missing_path(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_upload_file", {}))
assert "error" in result
def test_dispatch_file_upload_not_found(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_upload_file", {"local_path": "/nonexistent/file.txt"}))
assert "File not found" in result["error"]
p.shutdown()
def test_dispatch_file_read_requires_id(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_read_file", {}))
assert result == {"error": "file_id is required"}
p.shutdown()
def test_dispatch_file_ingest_requires_id(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_ingest_file", {}))
assert result == {"error": "file_id is required"}
p.shutdown()
def test_dispatch_file_delete_requires_id(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
result = json.loads(p.handle_tool_call("retaindb_delete_file", {}))
assert result == {"error": "file_id is required"}
p.shutdown()
def test_handle_tool_call_wraps_exception(self, tmp_path, monkeypatch):
p = self._make_provider(tmp_path, monkeypatch)
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
with patch.object(p._client, "get_profile", side_effect=RuntimeError("API exploded")):
result = json.loads(p.handle_tool_call("retaindb_profile", {}))
assert "API exploded" in result["error"]
p.shutdown()
# ===========================================================================
# Prefetch and thread management tests
# ===========================================================================
class TestPrefetch:
"""Test background prefetch and thread accumulation prevention."""
def _make_initialized_provider(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
return p
def test_queue_prefetch_skips_without_client(self):
p = RetainDBMemoryProvider()
p.queue_prefetch("test") # Should not raise
def test_prefetch_returns_empty_when_nothing_cached(self, tmp_path, monkeypatch):
p = self._make_initialized_provider(tmp_path, monkeypatch)
result = p.prefetch("test")
assert result == ""
p.shutdown()
def test_prefetch_consumes_context_result(self, tmp_path, monkeypatch):
p = self._make_initialized_provider(tmp_path, monkeypatch)
# Manually set the cached result
with p._lock:
p._context_result = "[RetainDB Context]\nProfile:\n- User likes tests"
result = p.prefetch("test")
assert "User likes tests" in result
# Should be consumed
assert p.prefetch("test") == ""
p.shutdown()
def test_prefetch_consumes_dialectic_result(self, tmp_path, monkeypatch):
p = self._make_initialized_provider(tmp_path, monkeypatch)
with p._lock:
p._dialectic_result = "User is a software engineer who prefers Python."
result = p.prefetch("test")
assert "[RetainDB User Synthesis]" in result
assert "software engineer" in result
p.shutdown()
def test_prefetch_consumes_agent_model(self, tmp_path, monkeypatch):
p = self._make_initialized_provider(tmp_path, monkeypatch)
with p._lock:
p._agent_model = {
"memory_count": 5,
"persona": "Helpful coding assistant",
"persistent_instructions": ["Be concise", "Use Python"],
"working_style": "Direct and efficient",
}
result = p.prefetch("test")
assert "[RetainDB Agent Self-Model]" in result
assert "Helpful coding assistant" in result
assert "Be concise" in result
assert "Direct and efficient" in result
p.shutdown()
def test_prefetch_skips_empty_agent_model(self, tmp_path, monkeypatch):
p = self._make_initialized_provider(tmp_path, monkeypatch)
with p._lock:
p._agent_model = {"memory_count": 0}
result = p.prefetch("test")
assert "Agent Self-Model" not in result
p.shutdown()
def test_thread_accumulation_guard(self, tmp_path, monkeypatch):
"""Verify old prefetch threads are joined before new ones spawn."""
p = self._make_initialized_provider(tmp_path, monkeypatch)
# Mock the prefetch methods to be slow
with patch.object(p, "_prefetch_context", side_effect=lambda q: time.sleep(0.5)), \
patch.object(p, "_prefetch_dialectic", side_effect=lambda q: time.sleep(0.5)), \
patch.object(p, "_prefetch_agent_model", side_effect=lambda: time.sleep(0.5)):
p.queue_prefetch("query 1")
first_threads = list(p._prefetch_threads)
assert len(first_threads) == 3
# Call again — should join first batch before spawning new
p.queue_prefetch("query 2")
second_threads = list(p._prefetch_threads)
assert len(second_threads) == 3
# Should be different thread objects
for t in second_threads:
assert t not in first_threads
p.shutdown()
def test_reasoning_level_short(self):
assert RetainDBMemoryProvider._reasoning_level("hi") == "low"
def test_reasoning_level_medium(self):
assert RetainDBMemoryProvider._reasoning_level("x" * 200) == "medium"
def test_reasoning_level_long(self):
assert RetainDBMemoryProvider._reasoning_level("x" * 500) == "high"
# ===========================================================================
# sync_turn tests
# ===========================================================================
class TestSyncTurn:
"""Test turn synchronization via the write queue."""
def test_sync_turn_enqueues(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
with patch.object(p._queue, "enqueue") as mock_enqueue:
p.sync_turn("user msg", "assistant msg")
mock_enqueue.assert_called_once()
args = mock_enqueue.call_args[0]
assert args[0] == "default" # user_id
assert args[1] == "test-session" # session_id
msgs = args[2]
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[1]["role"] == "assistant"
p.shutdown()
def test_sync_turn_skips_empty_user_content(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
with patch.object(p._queue, "enqueue") as mock_enqueue:
p.sync_turn("", "assistant msg")
mock_enqueue.assert_not_called()
p.shutdown()
# ===========================================================================
# on_memory_write hook tests
# ===========================================================================
class TestOnMemoryWrite:
"""Test the built-in memory mirror hook."""
def test_mirrors_add_action(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
with patch.object(p._client, "add_memory", return_value={"id": "mem-1"}) as mock_add:
p.on_memory_write("add", "user", "User prefers dark mode")
mock_add.assert_called_once()
assert mock_add.call_args[1]["memory_type"] == "preference"
p.shutdown()
def test_skips_non_add_action(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
with patch.object(p._client, "add_memory") as mock_add:
p.on_memory_write("remove", "user", "something")
mock_add.assert_not_called()
p.shutdown()
def test_skips_empty_content(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
with patch.object(p._client, "add_memory") as mock_add:
p.on_memory_write("add", "user", "")
mock_add.assert_not_called()
p.shutdown()
def test_memory_target_maps_to_type(self, tmp_path, monkeypatch):
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
p = RetainDBMemoryProvider()
p.initialize("test-session", hermes_home=str(hermes_home))
with patch.object(p._client, "add_memory", return_value={"id": "mem-1"}) as mock_add:
p.on_memory_write("add", "memory", "Some env fact")
assert mock_add.call_args[1]["memory_type"] == "factual"
p.shutdown()
# ===========================================================================
# register() test
# ===========================================================================
class TestRegister:
def test_register_calls_register_memory_provider(self):
from plugins.memory.retaindb import register
ctx = MagicMock()
register(ctx)
ctx.register_memory_provider.assert_called_once()
arg = ctx.register_memory_provider.call_args[0][0]
assert isinstance(arg, RetainDBMemoryProvider)
@@ -0,0 +1,332 @@
"""Tests for the security-guidance plugin.
Covers ``plugins/security-guidance/``:
* ``patterns.py`` data integrity — every rule has a ``RuleId``, the
fail-loud import assertion is wired.
* ``_scan_content`` — true positives (pickle.load, yaml.load, eval,
dangerouslySetInnerHTML, GitHub Actions workflow), true negatives
(.md skips Python rules, ``model.eval()`` doesn't trip eval),
path-only rules (``path_check``), content-only rules
(``path_filter``).
* Hooks — ``transform_tool_result`` appends a warning block in warn
mode and stays out of error results; ``pre_tool_call`` blocks
writes when ``SECURITY_GUIDANCE_BLOCK=1`` and stays silent
otherwise.
* Bundled-plugin discovery via ``PluginManager.discover_and_load``.
"""
import importlib.util
import sys
import types
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _isolate_env(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SECURITY_GUIDANCE_BLOCK", raising=False)
monkeypatch.delenv("SECURITY_GUIDANCE_DISABLE", raising=False)
yield hermes_home
# ---------------------------------------------------------------------------
# Module loading
# ---------------------------------------------------------------------------
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _load_patterns():
"""Import patterns.py in isolation (no plugin glue)."""
pat_path = _repo_root() / "plugins" / "security-guidance" / "patterns.py"
spec = importlib.util.spec_from_file_location(
"security_guidance_patterns_under_test", pat_path
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _load_plugin_init():
"""Import the plugin __init__.py with patterns.py as a sibling."""
plugin_dir = _repo_root() / "plugins" / "security-guidance"
if "hermes_plugins" not in sys.modules:
ns = types.ModuleType("hermes_plugins")
ns.__path__ = []
sys.modules["hermes_plugins"] = ns
spec = importlib.util.spec_from_file_location(
"hermes_plugins.security_guidance",
plugin_dir / "__init__.py",
submodule_search_locations=[str(plugin_dir)],
)
mod = importlib.util.module_from_spec(spec)
mod.__package__ = "hermes_plugins.security_guidance"
mod.__path__ = [str(plugin_dir)]
sys.modules["hermes_plugins.security_guidance"] = mod
spec.loader.exec_module(mod)
return mod
# ---------------------------------------------------------------------------
# patterns.py data integrity
# ---------------------------------------------------------------------------
class TestPatternsData:
def test_has_at_least_one_rule(self):
p = _load_patterns()
assert len(p.SECURITY_PATTERNS) >= 1
def test_every_rule_has_required_fields(self):
p = _load_patterns()
for rule in p.SECURITY_PATTERNS:
assert "ruleName" in rule
assert "reminder" in rule and rule["reminder"]
# At least one of substrings/regex/path_check must be present —
# otherwise the rule could never fire.
assert any(k in rule for k in ("substrings", "regex", "path_check")), rule
def test_rule_names_are_unique(self):
p = _load_patterns()
names = [r["ruleName"] for r in p.SECURITY_PATTERNS]
assert len(names) == len(set(names))
def test_rule_id_enum_in_sync(self):
# The upstream patterns.py asserts this at import time. If the
# set diverges, the import itself raises and this test fails.
p = _load_patterns()
rule_names = {r["ruleName"] for r in p.SECURITY_PATTERNS}
enum_names = set(p._RULE_NAME_TO_ID)
assert rule_names == enum_names
def test_rule_names_to_mask_packs_bits(self):
p = _load_patterns()
# PICKLE_DESERIALIZATION = 8, EVAL_INJECTION = 4 → bits 8 and 4 set.
mask = p.rule_names_to_mask({"pickle_deserialization", "eval_injection"})
assert mask & (1 << p.RuleId.PICKLE_DESERIALIZATION)
assert mask & (1 << p.RuleId.EVAL_INJECTION)
# ---------------------------------------------------------------------------
# _scan_content
# ---------------------------------------------------------------------------
class TestScanContent:
def test_pickle_load_in_py_warns(self):
mod = _load_plugin_init()
findings = mod._scan_content(
"/tmp/foo.py", "import pickle\nx = pickle.load(open('p.pkl', 'rb'))\n"
)
names = [n for n, _ in findings]
assert "pickle_deserialization" in names
def test_pickle_load_in_md_skipped_by_path_filter(self):
mod = _load_plugin_init()
findings = mod._scan_content(
"/tmp/foo.md", "import pickle\nx = pickle.load(open('p.pkl', 'rb'))\n"
)
assert findings == []
def test_method_call_eval_does_not_trip(self):
"""model.eval() / redis.eval() / spec.eval() must not match eval_injection."""
mod = _load_plugin_init()
findings = mod._scan_content("/tmp/foo.py", "model.eval()\nout = model(x)\n")
assert "eval_injection" not in [n for n, _ in findings]
def test_bare_eval_in_py_warns(self):
mod = _load_plugin_init()
findings = mod._scan_content("/tmp/foo.py", "result = eval(user_input)\n")
assert "eval_injection" in [n for n, _ in findings]
def test_subprocess_shell_true_warns(self):
mod = _load_plugin_init()
findings = mod._scan_content(
"/tmp/foo.py", "subprocess.run('ls ' + path, shell=True)\n"
)
assert "python_subprocess_shell" in [n for n, _ in findings]
def test_dangerously_set_inner_html_warns(self):
mod = _load_plugin_init()
findings = mod._scan_content(
"/tmp/foo.tsx", "<div dangerouslySetInnerHTML={{__html: x}} />"
)
assert "react_dangerously_set_html" in [n for n, _ in findings]
def test_github_workflow_path_check_fires_on_path_alone(self):
"""github_actions_workflow has no regex/substring — fires on path."""
mod = _load_plugin_init()
findings = mod._scan_content(
".github/workflows/test.yml", "name: CI\non: pull_request"
)
assert "github_actions_workflow" in [n for n, _ in findings]
def test_non_workflow_path_doesnt_trip_workflow_rule(self):
mod = _load_plugin_init()
findings = mod._scan_content("/tmp/foo.py", "name: CI")
assert "github_actions_workflow" not in [n for n, _ in findings]
def test_empty_content_returns_no_findings(self):
mod = _load_plugin_init()
assert mod._scan_content("/tmp/foo.py", "") == []
def test_huge_content_skipped(self):
mod = _load_plugin_init()
# 1 MB of content with a dangerous pattern at the end — scanner caps
# out at _MAX_SCAN_BYTES (256 KB), so this should return [].
big = "x" * (1024 * 1024) + "\npickle.load(open('p.pkl', 'rb'))\n"
assert mod._scan_content("/tmp/foo.py", big) == []
# ---------------------------------------------------------------------------
# Hooks
# ---------------------------------------------------------------------------
class TestTransformToolResultHook:
def test_warns_on_write_file_with_dangerous_content(self):
mod = _load_plugin_init()
args = {
"path": "/tmp/foo.py",
"content": "import pickle\nx = pickle.loads(b)\n",
}
result = mod._on_transform_tool_result(
tool_name="write_file",
args=args,
result='{"success": true, "bytes_written": 30}',
)
assert isinstance(result, str)
assert "Security guidance" in result
assert "pickle_deserialization" in result
# The original JSON should still be there at the start of the string.
assert result.startswith('{"success": true')
def test_no_warn_on_clean_content(self):
mod = _load_plugin_init()
args = {"path": "/tmp/foo.py", "content": "import json\nx = json.loads(b)\n"}
assert (
mod._on_transform_tool_result(
tool_name="write_file", args=args, result='{"success": true}'
)
is None
)
def test_no_warn_when_result_is_error(self):
mod = _load_plugin_init()
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
# When the tool itself errored, we don't pile a security warning on
# top — the model has bigger problems to solve.
assert (
mod._on_transform_tool_result(
tool_name="write_file", args=args, result='{"error": "boom"}'
)
is None
)
def test_patch_tool_new_string_scanned(self):
mod = _load_plugin_init()
args = {
"path": "/tmp/foo.py",
"old_string": "x = 1",
"new_string": "x = eval(user_input)",
}
result = mod._on_transform_tool_result(
tool_name="patch", args=args, result='{"success": true}'
)
assert isinstance(result, str)
assert "eval_injection" in result
def test_untargeted_tool_skipped(self):
mod = _load_plugin_init()
# The plugin only scans write_file/patch/skill_manage. terminal output
# should pass through untouched.
args = {"command": "echo pickle.load"}
assert (
mod._on_transform_tool_result(
tool_name="terminal", args=args, result='{"output": "pickle.load"}'
)
is None
)
def test_disable_kill_switch(self, monkeypatch):
mod = _load_plugin_init()
monkeypatch.setenv("SECURITY_GUIDANCE_DISABLE", "1")
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
assert (
mod._on_transform_tool_result(
tool_name="write_file", args=args, result='{"ok": true}'
)
is None
)
def test_block_mode_makes_transform_hook_quiet(self, monkeypatch):
"""In block mode, pre_tool_call handles the warning; the transform
hook stays silent so we don't double-emit."""
mod = _load_plugin_init()
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
assert (
mod._on_transform_tool_result(
tool_name="write_file", args=args, result='{"ok": true}'
)
is None
)
class TestPreToolCallHook:
def test_no_block_in_warn_mode(self):
mod = _load_plugin_init()
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
assert mod._on_pre_tool_call(tool_name="write_file", args=args) is None
def test_blocks_in_block_mode_on_dangerous_pattern(self, monkeypatch):
mod = _load_plugin_init()
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
out = mod._on_pre_tool_call(tool_name="write_file", args=args)
assert isinstance(out, dict)
assert out["action"] == "block"
assert "pickle_deserialization" in out["message"]
assert "SECURITY_GUIDANCE_BLOCK" in out["message"] # tells user how to disable
def test_no_block_in_block_mode_on_clean_content(self, monkeypatch):
mod = _load_plugin_init()
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
args = {"path": "/tmp/foo.py", "content": "import json\n"}
assert mod._on_pre_tool_call(tool_name="write_file", args=args) is None
def test_untargeted_tool_skipped(self, monkeypatch):
mod = _load_plugin_init()
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
args = {"command": "echo pickle.load(f)"}
assert mod._on_pre_tool_call(tool_name="terminal", args=args) is None
# ---------------------------------------------------------------------------
# Bundled-plugin discovery
# ---------------------------------------------------------------------------
class TestPluginDiscovery:
def test_loads_via_plugin_manager(self, _isolate_env, monkeypatch):
"""End-to-end: enable in config.yaml and verify the PluginManager
picks it up via the standard discovery path."""
import yaml
config = {"plugins": {"enabled": ["security-guidance"]}}
(_isolate_env / "config.yaml").write_text(yaml.safe_dump(config))
# Wipe any cached plugin state from earlier tests in this worker.
for k in list(sys.modules):
if k.startswith(("hermes_plugins", "hermes_cli.plugins")):
del sys.modules[k]
from hermes_cli.plugins import _ensure_plugins_discovered
mgr = _ensure_plugins_discovered(force=True)
loaded = set()
if hasattr(mgr, "_plugins"):
loaded = set(mgr._plugins.keys())
assert "security-guidance" in loaded
+575
View File
@@ -0,0 +1,575 @@
"""Tests for the Teams pipeline plugin package."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from pathlib import Path
import pytest
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from plugins.teams_pipeline import register
from plugins.teams_pipeline.pipeline import TeamsMeetingPipeline
from plugins.teams_pipeline.store import TeamsPipelineStore
from plugins.teams_pipeline.models import MeetingArtifact
class FakeGraphClient:
def __init__(self) -> None:
self.downloaded = False
async def _transcript_meeting_resolver(client, *, meeting_id=None, join_web_url=None, tenant_id=None):
from plugins.teams_pipeline.models import TeamsMeetingRef
return TeamsMeetingRef(
meeting_id=str(meeting_id),
tenant_id=tenant_id,
metadata={"subject": "Weekly Sync", "participants": [{"displayName": "Ada"}]},
)
async def _no_call_record(*args, **kwargs):
return None
def test_register_adds_cli_only():
mgr = PluginManager()
manifest = PluginManifest(name="teams_pipeline")
ctx = PluginContext(manifest, mgr)
register(ctx)
assert "teams-pipeline" in mgr._cli_commands
entry = mgr._cli_commands["teams-pipeline"]
assert entry["plugin"] == "teams_pipeline"
assert callable(entry["setup_fn"])
assert callable(entry["handler_fn"])
def test_runtime_config_uses_existing_teams_platform_settings():
from plugins.teams_pipeline.runtime import build_pipeline_runtime_config
gateway_config = GatewayConfig(
platforms={
Platform("teams"): PlatformConfig(
enabled=True,
extra={
"delivery_mode": "graph",
"team_id": "team-1",
"channel_id": "channel-1",
"meeting_pipeline": {
"transcript_min_chars": 120,
"notion": {"enabled": True, "database_id": "db-1"},
},
},
)
}
)
runtime_config = build_pipeline_runtime_config(gateway_config)
assert runtime_config["transcript_min_chars"] == 120
assert runtime_config["notion"]["database_id"] == "db-1"
assert runtime_config["teams_delivery"] == {
"enabled": True,
"mode": "graph",
"team_id": "team-1",
"channel_id": "channel-1",
}
def test_build_pipeline_runtime_reuses_existing_teams_adapter_surface(monkeypatch, tmp_path):
from plugins.teams_pipeline import runtime as runtime_module
class FakeWriter:
def __init__(self, platform_config=None, **kwargs) -> None:
self.platform_config = platform_config
monkeypatch.setattr(runtime_module, "build_graph_client", lambda: object())
monkeypatch.setattr(runtime_module, "resolve_teams_pipeline_store_path", lambda: tmp_path / "teams-store.json")
monkeypatch.setattr("plugins.platforms.teams.adapter.TeamsSummaryWriter", FakeWriter)
gateway = SimpleNamespace(
config=GatewayConfig(
platforms={
Platform("teams"): PlatformConfig(
enabled=True,
extra={
"delivery_mode": "incoming_webhook",
"incoming_webhook_url": "https://example.com/hook",
},
)
}
)
)
runtime = runtime_module.build_pipeline_runtime(gateway)
assert isinstance(runtime.teams_sender, FakeWriter)
assert runtime.teams_sender.platform_config is gateway.config.platforms[Platform("teams")]
@pytest.mark.anyio
async def test_bind_gateway_runtime_attaches_scheduler(monkeypatch, tmp_path):
from plugins.teams_pipeline import runtime as runtime_module
class FakeAdapter:
def __init__(self) -> None:
self.scheduler = None
def set_notification_scheduler(self, scheduler) -> None:
self.scheduler = scheduler
class FakePipeline:
def __init__(self) -> None:
self.notifications = []
async def run_notification(self, notification):
self.notifications.append(notification)
adapter = FakeAdapter()
pipeline = FakePipeline()
gateway = SimpleNamespace(
adapters={Platform.MSGRAPH_WEBHOOK: adapter},
config=GatewayConfig(platforms={}),
_teams_pipeline_runtime=None,
_teams_pipeline_runtime_error=None,
)
monkeypatch.setattr(runtime_module, "build_pipeline_runtime", lambda gateway_runner: pipeline)
bound = runtime_module.bind_gateway_runtime(gateway)
assert bound is True
assert gateway._teams_pipeline_runtime is pipeline
assert callable(adapter.scheduler)
notification = {"id": "notif-1"}
await adapter.scheduler(notification, object())
assert pipeline.notifications == [notification]
@pytest.mark.anyio
async def test_bind_gateway_runtime_drops_notifications_when_unavailable(monkeypatch):
from plugins.teams_pipeline import runtime as runtime_module
from tools.microsoft_graph_auth import MicrosoftGraphConfigError
class FakeAdapter:
def __init__(self) -> None:
self.scheduler = None
def set_notification_scheduler(self, scheduler) -> None:
self.scheduler = scheduler
adapter = FakeAdapter()
gateway = SimpleNamespace(
adapters={Platform.MSGRAPH_WEBHOOK: adapter},
config=GatewayConfig(platforms={}),
_teams_pipeline_runtime=None,
_teams_pipeline_runtime_error=None,
)
def _raise(_gateway_runner):
raise MicrosoftGraphConfigError("missing graph env")
monkeypatch.setattr(runtime_module, "build_pipeline_runtime", _raise)
bound = runtime_module.bind_gateway_runtime(gateway)
assert bound is False
assert "missing graph env" in gateway._teams_pipeline_runtime_error
assert callable(adapter.scheduler)
await adapter.scheduler({"id": "notif-2"}, object())
def test_store_persists_subscription_event_and_job_state(tmp_path):
store_path = tmp_path / "teams-store.json"
store = TeamsPipelineStore(store_path)
store.upsert_subscription(
"sub-1",
{"client_state": "abc", "resource": "communications/onlineMeetings"},
)
store.record_event_timestamp("evt-1", "2026-05-03T19:30:00Z")
store.upsert_job("job-1", {"status": "received", "event_id": "evt-1"})
store.upsert_sink_record("notion:meeting-1", {"page_id": "page-1"})
reloaded = TeamsPipelineStore(store_path)
subscription = reloaded.get_subscription("sub-1")
job = reloaded.get_job("job-1")
sink = reloaded.get_sink_record("notion:meeting-1")
assert subscription is not None
assert subscription["subscription_id"] == "sub-1"
assert subscription["client_state"] == "abc"
assert reloaded.get_event_timestamp("evt-1") == "2026-05-03T19:30:00Z"
assert job is not None
assert job["status"] == "received"
assert sink is not None
assert sink["page_id"] == "page-1"
def test_store_notification_receipts_are_idempotent(tmp_path):
store = TeamsPipelineStore(tmp_path / "teams-store.json")
notification = {
"subscriptionId": "sub-1",
"resource": "communications/onlineMeetings/meeting-1",
"changeType": "updated",
}
receipt_key = TeamsPipelineStore.build_notification_receipt_key(notification)
assert store.record_notification_receipt(receipt_key, notification) is True
assert store.record_notification_receipt(receipt_key, notification) is False
assert store.has_notification_receipt(receipt_key) is True
reloaded = TeamsPipelineStore(tmp_path / "teams-store.json")
assert reloaded.has_notification_receipt(receipt_key) is True
@pytest.mark.anyio
class TestTeamsMeetingPipeline:
async def test_transcript_first_path_persists_state_and_skips_recording(self, tmp_path, monkeypatch):
from plugins.teams_pipeline import pipeline as pipeline_module
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
async def _fetch_transcript(client, meeting_ref):
return (
MeetingArtifact(artifact_type="transcript", artifact_id="tx-1", display_name="meeting.vtt"),
"Action: Send draft by Friday.\nDecision: Ship the transcript-first path.\nDetailed transcript content.",
)
async def _call_record(client, meeting_ref, *, call_record_id=None, allow_permission_errors=True):
return MeetingArtifact(
artifact_type="call_record",
artifact_id="call-1",
metadata={"metrics": {"participant_count": 4}},
)
async def _summarize(**kwargs):
return pipeline_module.TeamsMeetingSummaryPayload(
meeting_ref=kwargs["resolved_meeting"],
title="Weekly Sync",
transcript_text=kwargs["transcript_text"],
summary="Short summary",
key_decisions=["Ship the transcript-first path."],
action_items=["Send draft by Friday."],
risks=["Timeline risk."],
confidence="high",
confidence_notes="Transcript available.",
source_artifacts=kwargs["artifacts"],
)
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _fetch_transcript)
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _call_record)
store = TeamsPipelineStore(tmp_path / "teams-store.json")
pipeline = TeamsMeetingPipeline(
graph_client=FakeGraphClient(),
store=store,
config={"transcript_min_chars": 20},
summarize_fn=_summarize,
)
job = await pipeline.run_notification(
{
"id": "notif-1",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-123",
"resourceData": {"id": "meeting-123"},
}
)
assert job.status == "completed"
assert job.selected_artifact_strategy == "transcript_first"
assert job.summary_payload is not None
assert job.summary_payload.summary == "Short summary"
stored = store.get_job(job.job_id)
assert stored is not None
assert stored["status"] == "completed"
async def test_recording_fallback_uses_stt_and_updates_sink_records(self, tmp_path, monkeypatch):
from plugins.teams_pipeline import pipeline as pipeline_module
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
async def _no_transcript(client, meeting_ref):
return None, None
async def _recordings(client, meeting_ref):
return [
MeetingArtifact(
artifact_type="recording",
artifact_id="rec-1",
display_name="../../nested/recording.mp4",
download_url="https://files.example/recording.mp4",
)
]
downloaded_targets = []
async def _download(client, meeting_ref, recording, destination):
target = Path(destination)
downloaded_targets.append(target)
target.write_bytes(b"video-bytes")
return {"path": str(target), "size_bytes": 11, "content_type": "video/mp4"}
async def _prepare_audio(self, recording_path):
audio_path = recording_path.with_suffix(".wav")
audio_path.write_bytes(b"audio-bytes")
return audio_path
def _transcribe(file_path, model):
return {"success": True, "transcript": "Action: Follow up with Legal.\nRisk: Budget approval pending.", "provider": "local"}
async def _summarize(**kwargs):
return pipeline_module.TeamsMeetingSummaryPayload(
meeting_ref=kwargs["resolved_meeting"],
title="Weekly Sync",
transcript_text=kwargs["transcript_text"],
summary="Fallback summary",
key_decisions=[],
action_items=["Follow up with Legal."],
risks=["Budget approval pending."],
confidence="medium",
confidence_notes="Generated from STT fallback.",
source_artifacts=kwargs["artifacts"],
)
class FakeNotionWriter:
async def write_summary(self, payload, config, existing_record=None):
return {"page_id": existing_record.get("page_id") if existing_record else "page-1", "url": "https://notion.so/page-1"}
async def _teams_sender(payload, config, existing_record=None):
return {"message_id": existing_record.get("message_id") if existing_record else "msg-1"}
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _no_transcript)
monkeypatch.setattr(pipeline_module, "list_recording_artifacts", _recordings)
monkeypatch.setattr(pipeline_module, "download_recording_artifact", _download)
monkeypatch.setattr(pipeline_module.TeamsMeetingPipeline, "_prepare_audio_path", _prepare_audio)
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _no_call_record)
store = TeamsPipelineStore(tmp_path / "teams-store.json")
pipeline = TeamsMeetingPipeline(
graph_client=FakeGraphClient(),
store=store,
config={
"notion": {"enabled": True, "database_id": "db-1"},
"teams_delivery": {"enabled": True, "channel_id": "channel-1"},
},
transcribe_fn=_transcribe,
summarize_fn=_summarize,
notion_writer=FakeNotionWriter(),
teams_sender=_teams_sender,
)
job = await pipeline.run_notification(
{
"id": "notif-2",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-456",
"resourceData": {"id": "meeting-456"},
}
)
assert job.status == "completed"
assert job.selected_artifact_strategy == "recording_stt_fallback"
assert job.summary_payload is not None
assert job.summary_payload.summary == "Fallback summary"
assert downloaded_targets
assert downloaded_targets[0].name == "recording.mp4"
assert "nested" not in str(downloaded_targets[0])
notion_record = store.get_sink_record("notion:meeting-456")
teams_record = store.get_sink_record("teams:meeting-456")
assert notion_record is not None
assert notion_record["page_id"] == "page-1"
assert teams_record is not None
assert teams_record["message_id"] == "msg-1"
@pytest.mark.parametrize("crafted_name", ["..", "../", ".", ""])
async def test_recording_dot_only_display_name_falls_back_to_artifact_id(
self, tmp_path, monkeypatch, crafted_name
):
# Path("..").name == ".." and Path(".").name == "" — so basename
# extraction alone does not neutralize dot-only names. Joining
# tmp_dir / ".." resolves to the parent directory (an escape), so
# the pipeline must reject these and fall back to the artifact id.
from plugins.teams_pipeline import pipeline as pipeline_module
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
async def _no_transcript(client, meeting_ref):
return None, None
async def _recordings(client, meeting_ref):
return [
MeetingArtifact(
artifact_type="recording",
artifact_id="rec-dot",
display_name=crafted_name,
download_url="https://files.example/recording.mp4",
)
]
downloaded_targets = []
async def _download(client, meeting_ref, recording, destination):
target = Path(destination)
downloaded_targets.append(target)
target.write_bytes(b"video-bytes")
return {"path": str(target), "size_bytes": 11, "content_type": "video/mp4"}
async def _prepare_audio(self, recording_path):
audio_path = recording_path.with_suffix(".wav")
audio_path.write_bytes(b"audio-bytes")
return audio_path
def _transcribe(file_path, model):
return {"success": True, "transcript": "Action: Follow up.", "provider": "local"}
async def _summarize(**kwargs):
return pipeline_module.TeamsMeetingSummaryPayload(
meeting_ref=kwargs["resolved_meeting"],
title="Weekly Sync",
transcript_text=kwargs["transcript_text"],
summary="Fallback summary",
key_decisions=[],
action_items=["Follow up."],
risks=[],
confidence="medium",
confidence_notes="Generated from STT fallback.",
source_artifacts=kwargs["artifacts"],
)
class FakeNotionWriter:
async def write_summary(self, payload, config, existing_record=None):
return {"page_id": "page-1", "url": "https://notion.so/page-1"}
async def _teams_sender(payload, config, existing_record=None):
return {"message_id": "msg-1"}
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _no_transcript)
monkeypatch.setattr(pipeline_module, "list_recording_artifacts", _recordings)
monkeypatch.setattr(pipeline_module, "download_recording_artifact", _download)
monkeypatch.setattr(pipeline_module.TeamsMeetingPipeline, "_prepare_audio_path", _prepare_audio)
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _no_call_record)
temp_root = tmp_path / "teams-tmp"
store = TeamsPipelineStore(tmp_path / "teams-store.json")
pipeline = TeamsMeetingPipeline(
graph_client=FakeGraphClient(),
store=store,
config={
"tmp_dir": str(temp_root),
"notion": {"enabled": True, "database_id": "db-1"},
"teams_delivery": {"enabled": True, "channel_id": "channel-1"},
},
transcribe_fn=_transcribe,
summarize_fn=_summarize,
notion_writer=FakeNotionWriter(),
teams_sender=_teams_sender,
)
job = await pipeline.run_notification(
{
"id": "notif-dot",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-dot",
"resourceData": {"id": "meeting-dot"},
}
)
assert job.status == "completed"
assert downloaded_targets
target = downloaded_targets[0]
# Fell back to the artifact id, not the crafted dot-only name.
assert target.name == "rec-dot.mp4"
# Stayed inside the generated temp recording directory (no escape).
assert target.resolve().parent.parent == temp_root.resolve()
assert target.resolve().parent.name.startswith("teams-recording-")
async def test_missing_transcript_and_recording_schedules_retry(self, tmp_path, monkeypatch):
from plugins.teams_pipeline import pipeline as pipeline_module
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", lambda *a, **kw: asyncio.sleep(0, result=(None, None)))
monkeypatch.setattr(pipeline_module, "list_recording_artifacts", lambda *a, **kw: asyncio.sleep(0, result=[]))
store = TeamsPipelineStore(tmp_path / "teams-store.json")
pipeline = TeamsMeetingPipeline(
graph_client=FakeGraphClient(),
store=store,
config={},
summarize_fn=lambda **kwargs: asyncio.sleep(0, result=None),
)
job = await pipeline.run_notification(
{
"id": "notif-3",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-789",
"resourceData": {"id": "meeting-789"},
}
)
assert job.status == "retry_scheduled"
assert job.error_info["retryable"] is True
assert "Recording unavailable" in job.error_info["message"]
async def test_duplicate_notification_reuses_completed_job(self, tmp_path, monkeypatch):
from plugins.teams_pipeline import pipeline as pipeline_module
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
async def _fetch_transcript(client, meeting_ref):
return (
MeetingArtifact(artifact_type="transcript", artifact_id="tx-dup", display_name="meeting.vtt"),
"Decision: Keep duplicate notifications idempotent.\nAction: Verify the cached job is reused.",
)
summarize_calls = 0
async def _summarize(**kwargs):
nonlocal summarize_calls
summarize_calls += 1
return pipeline_module.TeamsMeetingSummaryPayload(
meeting_ref=kwargs["resolved_meeting"],
title="Weekly Sync",
transcript_text=kwargs["transcript_text"],
summary="Duplicate-safe summary",
key_decisions=["Keep duplicate notifications idempotent."],
action_items=["Verify the cached job is reused."],
confidence="high",
confidence_notes="Transcript available.",
source_artifacts=kwargs["artifacts"],
)
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _fetch_transcript)
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _no_call_record)
store = TeamsPipelineStore(tmp_path / "teams-store.json")
pipeline = TeamsMeetingPipeline(
graph_client=FakeGraphClient(),
store=store,
config={"transcript_min_chars": 20},
summarize_fn=_summarize,
)
notification = {
"id": "notif-dup",
"changeType": "updated",
"resource": "communications/onlineMeetings/meeting-dup",
"resourceData": {"id": "meeting-dup"},
}
first_job = await pipeline.run_notification(notification)
second_job = await pipeline.run_notification(notification)
assert first_job.status == "completed"
assert second_job.status == "completed"
assert second_job.job_id == first_job.job_id
assert summarize_calls == 1
assert len(store.list_jobs()) == 1
receipt_key = TeamsPipelineStore.build_notification_receipt_key(notification)
assert store.has_notification_receipt(receipt_key) is True
@@ -0,0 +1,431 @@
"""Behavior-parity check for the STT plugin hook + command-provider registry.
Spawns one subprocess per (version, scenario) cell — pinned to either
``origin/main`` (no plugin hook, no STT command-provider registry; only
the legacy ``HERMES_LOCAL_STT_COMMAND`` escape hatch exists) or this PR's
worktree (both new surfaces present).
Each subprocess clears all STT-related env vars + writes a
``config.yaml``, then asks the dispatcher how it would route a
``transcribe_audio`` call. The emitted shape tuple is::
{dispatch_kind, provider_name, success}
Where ``dispatch_kind`` ∈
``{"builtin_local", "builtin_groq", "builtin_openai", ...,
"plugin", "plugin_unavailable", "command_provider",
"no_provider_error", "stt_disabled"}``.
Acceptable diffs:
- ``no_provider_error → plugin`` for the ``plugin-installed`` scenario.
- ``no_provider_error → plugin_unavailable`` for the
``plugin-installed-unavailable`` scenario (PR returns the cleaner
unavailability envelope instead of the generic auto-detect error).
- ``no_provider_error → command_provider`` for the
``command-provider-installed`` scenario (registry shipped with this PR).
- ``no_provider_error → command_provider`` for
``command-vs-plugin-same-name`` (command wins precedence, same as TTS).
Run from the PR worktree::
python tests/plugins/transcription/check_parity_vs_main.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
def _resolve_main_dir() -> Path:
candidate = REPO_ROOT.parent.parent
if (candidate / "tools" / "transcription_tools.py").exists() and candidate != REPO_ROOT:
return candidate
sibling = REPO_ROOT.parent / "hermes-agent-main"
if (sibling / "tools" / "transcription_tools.py").exists():
return sibling
return REPO_ROOT
MAIN_DIR = _resolve_main_dir()
PR_DIR = REPO_ROOT
assert (PR_DIR / "tools" / "transcription_tools.py").exists(), (
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
)
SUBPROCESS_SCRIPT = r"""
import json, os, sys, tempfile
sys.path.insert(0, sys.argv[1])
# Isolated HERMES_HOME so the config write is hermetic.
home = tempfile.mkdtemp()
os.environ["HERMES_HOME"] = home
# Clear STT-related env so dispatch decisions are config-driven.
for k in (
"GROQ_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY",
"MISTRAL_API_KEY", "XAI_API_KEY",
"HERMES_LOCAL_STT_COMMAND",
):
os.environ.pop(k, None)
scenario_env = json.loads(sys.argv[2])
os.environ.update(scenario_env)
config_yaml = sys.argv[3]
plugin_register = sys.argv[4] # "yes" to register a fake plugin
config_path = os.path.join(home, "config.yaml")
with open(config_path, "w") as f:
f.write(config_yaml)
# Fresh import — must not have anything cached from prior runs.
for name in list(sys.modules):
if (name.startswith("tools.")
or name.startswith("agent.")
or name.startswith("plugins.")
or name.startswith("hermes_cli.")):
sys.modules.pop(name, None)
# Try importing transcription_registry — only exists on PR side.
have_plugin_hook = False
try:
from agent import transcription_registry
from agent.transcription_provider import TranscriptionProvider
have_plugin_hook = True
if plugin_register == "yes":
class _FakeProvider(TranscriptionProvider):
@property
def name(self): return "openrouter"
def transcribe(self, file_path, **kw):
return {"success": True, "transcript": "PLUGIN: openrouter transcript", "provider": "openrouter"}
transcription_registry._reset_for_tests()
transcription_registry.register_provider(_FakeProvider())
elif plugin_register == "unavailable":
class _UnavailablePlugin(TranscriptionProvider):
@property
def name(self): return "openrouter"
def is_available(self): return False
def transcribe(self, file_path, **kw):
return {"success": True, "transcript": "should not run"}
transcription_registry._reset_for_tests()
transcription_registry.register_provider(_UnavailablePlugin())
except ImportError:
pass
import tools.transcription_tools as tt
# Use a real (but empty) audio file so _validate_audio_file passes.
audio_path = os.path.join(home, "audio.ogg")
with open(audio_path, "wb") as f:
# Minimal-ish OGG-shaped bytes so the size check passes.
f.write(b"OggS" + b"\x00" * 1024)
# Patch _transcribe_* so the test doesn't actually try cloud APIs.
# We're testing dispatch, not the underlying transcription.
def _stub(file_path, model_name=None):
return {"success": True, "transcript": "stub from " + sys._getframe().f_code.co_name.replace("_stub_", ""),
"provider": sys._getframe().f_code.co_name.replace("_stub_", "")}
# Stub each built-in to a marker so we can identify the branch.
class _Stub:
def __init__(self, name):
self.name = name
def __call__(self, file_path, model_name=None):
return {"success": True, "transcript": "stub", "provider": self.name}
tt._transcribe_local = _Stub("local")
tt._transcribe_local_command = _Stub("local_command")
tt._transcribe_groq = _Stub("groq")
tt._transcribe_openai = _Stub("openai")
tt._transcribe_mistral = _Stub("mistral")
tt._transcribe_xai = _Stub("xai")
# Force _get_provider to honor the explicit config since we don't have
# real creds. The provider-resolution gates check _HAS_OPENAI /
# _HAS_FASTER_WHISPER which we can't easily set, so we just patch
# _get_provider to return whatever the config says.
stt_cfg = tt._load_stt_config()
explicit = stt_cfg.get("provider")
if explicit:
# Bypass the gating for test purposes — _get_provider would
# otherwise return "none" when the dependency isn't installed.
original_get = tt._get_provider
def _patched(cfg):
if not tt.is_stt_enabled(cfg):
return "none"
return cfg.get("provider", "none")
tt._get_provider = _patched
try:
result = tt.transcribe_audio(audio_path)
except Exception as exc:
shape = {"dispatch_kind": "exception", "provider_name": None, "success": False,
"error_text": repr(exc)}
print(json.dumps(shape))
sys.exit(0)
dispatch_kind = "unknown"
provider_name = result.get("provider") if isinstance(result, dict) else None
success = result.get("success", False) if isinstance(result, dict) else False
error_text = result.get("error", "") if isinstance(result, dict) else ""
if not success and "STT is disabled" in error_text:
dispatch_kind = "stt_disabled"
elif not success and "is not available" in error_text:
dispatch_kind = "plugin_unavailable"
elif not success and "No STT provider" in error_text:
dispatch_kind = "no_provider_error"
elif provider_name in ("local", "local_command", "groq", "openai", "mistral", "xai"):
dispatch_kind = "builtin_" + provider_name
elif success and isinstance(result, dict) and result.get("transcript", "").startswith("CMD:"):
# Command-provider scenarios below emit transcripts prefixed with "CMD:"
# so the harness can distinguish command-provider dispatch from a
# plugin dispatch even when they share a provider name.
dispatch_kind = "command_provider"
elif success and isinstance(result, dict) and result.get("transcript", "").startswith("PLUGIN:"):
dispatch_kind = "plugin"
elif success and provider_name and provider_name not in ("local", "local_command", "groq", "openai", "mistral", "xai"):
dispatch_kind = "plugin"
else:
dispatch_kind = "other"
shape = {
"dispatch_kind": dispatch_kind,
"provider_name": provider_name,
"success": success,
}
print(json.dumps(shape))
"""
def _cmd_yaml(provider_name: str, transcript: str) -> str:
"""Build a YAML snippet for an stt.providers.<name>: type: command entry.
Produces a shell command that writes ``transcript`` to {output_path}.
Backslashes in the venv python path are doubled for YAML, and the
inner double quotes around the python -c payload are YAML-escaped.
Keeps the test scenarios readable.
"""
interp = sys.executable.replace("\\", "\\\\")
# Inside the YAML double-quoted string, we use single quotes around
# the python -c body so we don't have to YAML-escape inner double
# quotes. Single quotes inside the body are not needed; the body uses
# double quotes for module references and string literals.
payload = (
f"import sys; open(sys.argv[1], 'w').write('{transcript}')"
)
command = f'{interp} -c "{payload}" {{output_path}}'
# YAML-escape: double-quote the whole thing, escape inner " and \.
yaml_escaped = command.replace("\\", "\\\\").replace('"', '\\"')
return (
"stt:\n"
f" provider: {provider_name}\n"
" providers:\n"
f" {provider_name}:\n"
" type: command\n"
f' command: "{yaml_escaped}"\n'
)
SCENARIOS: list[tuple[str, str, dict[str, str], str]] = [
# (label, config.yaml body, scenario_env, plugin_register)
("stt-disabled", "stt:\n enabled: false\n", {}, "no"),
("explicit-groq", "stt:\n provider: groq\n", {}, "no"),
("explicit-openai", "stt:\n provider: openai\n", {}, "no"),
("explicit-local", "stt:\n provider: local\n", {}, "no"),
("explicit-xai", "stt:\n provider: xai\n", {}, "no"),
# Mistral is quarantined → _get_provider returns "none" today, hence no_provider_error.
("explicit-mistral-quarantine", "stt:\n provider: mistral\n", {}, "no"),
# Unknown name + no plugin → both: no_provider_error
("unknown-no-plugin", "stt:\n provider: openrouter\n", {}, "no"),
# Unknown name + plugin installed → main: no_provider_error, PR: plugin
("plugin-installed", "stt:\n provider: openrouter\n", {}, "yes"),
# Unknown name + plugin reports unavailable → main: no_provider_error,
# PR: plugin_unavailable (cleaner envelope, names the plugin)
("plugin-installed-unavailable", "stt:\n provider: openrouter\n", {}, "unavailable"),
# Built-in name + plugin tries to shadow → both: built-in
("explicit-openai-with-plugin-registered", "stt:\n provider: openai\n", {}, "yes"),
# NEW (this PR): stt.providers.<name>: type: command registry.
# Provider name "fake-cli" + transcript prefixed "CMD:" so dispatch_kind
# detection routes it to "command_provider". On main (no registry),
# this falls through to no_provider_error.
(
"command-provider-installed",
_cmd_yaml("fake-cli", "CMD: fake-cli transcript"),
{},
"no",
),
# NEW (this PR): same name registered as BOTH a command provider and
# a plugin under "openrouter". Command must win (config more local
# than plugin install). The plugin emits "PLUGIN:..." — assertion is
# that the transcript is "CMD:...", proving command-wins precedence.
(
"command-vs-plugin-same-name",
_cmd_yaml("openrouter", "CMD: openrouter via command wins"),
{},
"yes", # also register a plugin under "openrouter" — must NOT fire
),
# NEW (this PR): built-in name with a command provider declared under
# it → built-in still wins (built-in elif chain has precedence).
# The command would write "CMD: HIJACK" if it fired — assertion is
# that built-in OpenAI dispatch fires instead.
(
"explicit-openai-with-command-shadow",
_cmd_yaml("openai", "CMD: HIJACK"),
{},
"no",
),
]
# Subprocesses reset the registry between runs via ``_reset_for_tests`` so
# registrations from earlier scenarios don't leak. The command-provider
# scenarios also work on origin/main — the subprocess just executes the
# native dispatch path, which falls through to "no_provider_error" because
# main has no registry for stt.providers.<name>.
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict, plugin_register: str) -> dict:
venv_python = repo_path / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = MAIN_DIR / "venv" / "bin" / "python"
if not venv_python.exists():
venv_python = Path("python3")
out = subprocess.run(
[
str(venv_python),
"-c",
SUBPROCESS_SCRIPT,
str(repo_path),
json.dumps(env),
config_yaml,
plugin_register,
],
capture_output=True,
text=True,
timeout=60,
)
if out.returncode != 0:
return {
"error": "subprocess failed",
"stdout": out.stdout[-500:],
"stderr": out.stderr[-500:],
}
try:
return json.loads(out.stdout.strip().splitlines()[-1])
except Exception as exc:
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
def _reduce(shape: dict) -> dict:
return {
"dispatch_kind": shape.get("dispatch_kind"),
"success": shape.get("success"),
}
def main() -> int:
print(f"main: {MAIN_DIR}")
print(f"pr: {PR_DIR}")
print()
if MAIN_DIR == PR_DIR:
print(
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
" Set up a sibling 'hermes-agent-main' checkout pinned to "
"origin/main to get real parity coverage."
)
print()
failures: list[str] = []
errors: list[str] = []
intentional_diffs: list[tuple[str, dict, dict]] = []
for label, config_yaml, env, plugin_register in SCENARIOS:
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env, plugin_register)
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env, plugin_register)
if "error" in main_shape or "error" in pr_shape:
print(f" [ERR ] {label}: subprocess failed")
print(f" main: {main_shape}")
print(f" pr: {pr_shape}")
errors.append(label)
continue
main_reduced = _reduce(main_shape)
pr_reduced = _reduce(pr_shape)
if main_reduced == pr_reduced:
print(f" [OK] {label}: {main_reduced}")
continue
# On main, "plugin-installed" returns no_provider_error (no
# plugin hook); on PR, plugin dispatches. Same shape for
# "plugin-installed-unavailable" but PR returns the cleaner
# plugin_unavailable envelope. The new command-provider scenarios
# also intentionally diff against main (which has no stt.providers
# registry yet).
no_provider_to_plugin = (
main_reduced.get("dispatch_kind") == "no_provider_error"
and pr_reduced.get("dispatch_kind") == "plugin"
and label == "plugin-installed"
)
no_provider_to_unavailable = (
main_reduced.get("dispatch_kind") == "no_provider_error"
and pr_reduced.get("dispatch_kind") == "plugin_unavailable"
and label == "plugin-installed-unavailable"
)
no_provider_to_command = (
main_reduced.get("dispatch_kind") == "no_provider_error"
and pr_reduced.get("dispatch_kind") == "command_provider"
and label in {"command-provider-installed", "command-vs-plugin-same-name"}
)
if no_provider_to_plugin:
print(f" [DIFF] {label}: no_provider_error → plugin — expected")
intentional_diffs.append((label, main_reduced, pr_reduced))
elif no_provider_to_unavailable:
print(f" [DIFF] {label}: no_provider_error → plugin_unavailable — expected")
intentional_diffs.append((label, main_reduced, pr_reduced))
elif no_provider_to_command:
print(f" [DIFF] {label}: no_provider_error → command_provider — expected")
intentional_diffs.append((label, main_reduced, pr_reduced))
else:
print(f" [FAIL] {label}")
print(f" main: {main_reduced}")
print(f" pr: {pr_reduced}")
failures.append(label)
print()
if errors:
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
for e in errors:
print(f" - {e}")
if failures:
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
for f in failures:
print(f" - {f}")
if intentional_diffs:
print(
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
f"no_provider_error → plugin dispatch when a plugin is registered."
)
if failures or errors:
return 1
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
return 0
if __name__ == "__main__":
sys.exit(main())
View File
+328
View File
@@ -0,0 +1,328 @@
"""Behavior-parity check for the TTS plugin hook (issue #30398).
Spawns one subprocess per (version, scenario) cell — pinned to either
``origin/main`` (no plugin hook; ``tts.provider: cartesia`` falls
through to the Edge TTS default branch) or this PR's worktree (plugin
hook present; same config routes through the plugin registry when a
plugin is registered).
Each subprocess clears all TTS-related env vars + writes a
``config.yaml``, then resolves how the dispatcher would route a
``text_to_speech`` call. The emitted shape tuple is::
{dispatch_kind, provider_name, voice_compat}
Where ``dispatch_kind`` ∈
``{"builtin_edge", "builtin_openai", "builtin_elevenlabs", ...,
"command", "plugin", "fallback_edge", "error"}``:
* ``builtin_<name>`` — config selects a built-in handler that exists
on both main and PR (no diff expected)
* ``command`` — config selects a ``tts.providers.<name>: type: command``
entry (PR #17843; no diff expected)
* ``plugin`` — config selects a plugin-registered provider (PR only)
* ``fallback_edge`` — config selects an unknown name with no matching
plugin or command entry → Edge TTS default fallback
* ``error`` — explicit fatal error (e.g. mistral quarantine)
The parent process diffs the reduced shape per scenario. The only
acceptable diff is ``fallback_edge → plugin`` for the
``unknown-name-with-plugin-installed`` scenario — everything else is
a regression.
Run from the PR worktree (it auto-resolves ``MAIN_DIR`` from the parent
of the worktree directory, or falls back to a sibling
``hermes-agent-main`` checkout)::
python tests/plugins/tts/check_parity_vs_main.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
def _resolve_main_dir() -> Path:
candidate = REPO_ROOT.parent.parent
if (candidate / "tools" / "tts_tool.py").exists() and candidate != REPO_ROOT:
return candidate
sibling = REPO_ROOT.parent / "hermes-agent-main"
if (sibling / "tools" / "tts_tool.py").exists():
return sibling
return REPO_ROOT
MAIN_DIR = _resolve_main_dir()
PR_DIR = REPO_ROOT
assert (PR_DIR / "tools" / "tts_tool.py").exists(), (
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
)
# The subprocess script — runs INSIDE either the main checkout or PR
# checkout, so the import paths resolve to the version of the code
# under test. We never call the real ``text_to_speech_tool`` because
# that would require audio synthesis; instead we ask the resolution
# layer what it WOULD do.
SUBPROCESS_SCRIPT = r"""
import json, os, sys, tempfile
sys.path.insert(0, sys.argv[1])
# Isolated HERMES_HOME so the config write is hermetic.
home = tempfile.mkdtemp()
os.environ["HERMES_HOME"] = home
# Clear TTS-related env so dispatch decisions are config-driven.
for k in (
"ELEVENLABS_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY",
"MINIMAX_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY",
):
os.environ.pop(k, None)
scenario_env = json.loads(sys.argv[2])
os.environ.update(scenario_env)
config_yaml = sys.argv[3]
plugin_register = sys.argv[4] # "yes" to register a fake plugin
config_path = os.path.join(home, "config.yaml")
with open(config_path, "w") as f:
f.write(config_yaml)
# Fresh import — must not have anything cached from prior runs.
for name in list(sys.modules):
if (name.startswith("tools.")
or name.startswith("agent.")
or name.startswith("plugins.")
or name.startswith("hermes_cli.")):
sys.modules.pop(name, None)
# Try importing tts_registry — only exists on PR side.
have_plugin_hook = False
try:
from agent import tts_registry
from agent.tts_provider import TTSProvider
have_plugin_hook = True
if plugin_register == "yes":
class _FakeProvider(TTSProvider):
@property
def name(self): return "cartesia"
def synthesize(self, text, output_path, **kw):
return output_path
tts_registry._reset_for_tests()
tts_registry.register_provider(_FakeProvider())
except ImportError:
pass
import tools.tts_tool as tts_tool
# Read the config the same way text_to_speech_tool() does.
tts_config = tts_tool._load_tts_config()
provider = tts_tool._get_provider(tts_config)
dispatch_kind = None
provider_name = provider
voice_compat = False
error_text = None
try:
# Mistral is the one branch that returns a fatal error.
if provider == "mistral":
dispatch_kind = "error"
error_text = "mistral quarantine"
elif tts_tool._resolve_command_provider_config(provider, tts_config) is not None:
dispatch_kind = "command"
elif have_plugin_hook and provider not in tts_tool.BUILTIN_TTS_PROVIDERS:
# On PR side: check plugin dispatch.
plugin_path = tts_tool._dispatch_to_plugin_provider(
"test", os.path.join(home, "out.mp3"), provider, tts_config,
)
if plugin_path is not None:
dispatch_kind = "plugin"
voice_compat = tts_tool._plugin_provider_is_voice_compatible(provider)
else:
# Falls through to Edge TTS default on the PR side too.
dispatch_kind = "fallback_edge"
elif provider in tts_tool.BUILTIN_TTS_PROVIDERS:
dispatch_kind = "builtin_" + provider
else:
# On main side: unknown names fall through to Edge default.
dispatch_kind = "fallback_edge"
except Exception as exc:
dispatch_kind = "exception"
error_text = repr(exc)
shape = {
"dispatch_kind": dispatch_kind,
"provider_name": provider_name,
"voice_compat": bool(voice_compat),
"error_present": error_text is not None,
}
print(json.dumps(shape))
"""
SCENARIOS: list[tuple[str, str, dict[str, str], str]] = [
# (label, config.yaml body, scenario_env, plugin_register)
# Scenario 1: unset tts.provider → both: Edge default
("unset-defaults-to-edge", "", {}, "no"),
# Scenario 2: built-in name → both: that built-in
("explicit-edge", "tts:\n provider: edge\n", {}, "no"),
("explicit-openai", "tts:\n provider: openai\n", {}, "no"),
("explicit-elevenlabs", "tts:\n provider: elevenlabs\n", {}, "no"),
# Scenario 3: command-type provider → both: command dispatch
(
"command-provider",
"tts:\n provider: my-piper\n providers:\n my-piper:\n type: command\n command: 'piper -m model.onnx -f {output_path} < {input_path}'\n",
{},
"no",
),
# Scenario 4: unknown name with NO plugin installed → both: fallback to Edge
("unknown-no-plugin", "tts:\n provider: cartesia\n", {}, "no"),
# Scenario 5: unknown name WITH plugin installed
# main: fallback_edge (no plugin hook exists)
# PR: plugin (cartesia)
# This is the ONLY acceptable diff in the harness.
("plugin-installed", "tts:\n provider: cartesia\n", {}, "yes"),
# Scenario 6: built-in name + plugin tries to shadow → both: built-in
# The plugin registers under name "cartesia", not "edge", so this is
# effectively the same as scenario 2 — but we exercise the with-plugin
# path to ensure the built-in branch still takes priority.
("explicit-edge-with-plugin-registered", "tts:\n provider: edge\n", {}, "yes"),
# Scenario 7: mistral quarantine — both surface the explicit error
("mistral-quarantine", "tts:\n provider: mistral\n", {}, "no"),
]
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict, plugin_register: str) -> dict:
venv_python = repo_path / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = MAIN_DIR / "venv" / "bin" / "python"
if not venv_python.exists():
venv_python = Path("python3")
out = subprocess.run(
[
str(venv_python),
"-c",
SUBPROCESS_SCRIPT,
str(repo_path),
json.dumps(env),
config_yaml,
plugin_register,
],
capture_output=True,
text=True,
timeout=60,
)
if out.returncode != 0:
return {
"error": "subprocess failed",
"stdout": out.stdout[-500:],
"stderr": out.stderr[-500:],
}
try:
return json.loads(out.stdout.strip().splitlines()[-1])
except Exception as exc:
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
def _reduce(shape: dict) -> dict:
"""Reduce to the parts that matter for user-visible parity."""
return {
"dispatch_kind": shape.get("dispatch_kind"),
"provider_name": shape.get("provider_name"),
"error_present": shape.get("error_present"),
}
def main() -> int:
print(f"main: {MAIN_DIR}")
print(f"pr: {PR_DIR}")
print()
if MAIN_DIR == PR_DIR:
print(
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
" Set up a sibling 'hermes-agent-main' checkout pinned to "
"origin/main to get real parity coverage."
)
print()
failures: list[str] = []
errors: list[str] = []
intentional_diffs: list[tuple[str, dict, dict]] = []
for label, config_yaml, env, plugin_register in SCENARIOS:
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env, plugin_register)
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env, plugin_register)
if "error" in main_shape or "error" in pr_shape:
print(f" [ERR ] {label}: subprocess failed")
print(f" main: {main_shape}")
print(f" pr: {pr_shape}")
errors.append(label)
continue
main_reduced = _reduce(main_shape)
pr_reduced = _reduce(pr_shape)
if main_reduced == pr_reduced:
print(f" [OK] {label}: {main_reduced}")
continue
# On main, "plugin-installed" scenario returns fallback_edge
# (no plugin hook); on PR, it routes to the plugin. That's the
# only acceptable diff.
fallback_to_plugin = (
main_reduced.get("dispatch_kind") == "fallback_edge"
and pr_reduced.get("dispatch_kind") == "plugin"
and label == "plugin-installed"
)
if fallback_to_plugin:
print(f" [DIFF] {label}: fallback_edge → plugin — expected")
intentional_diffs.append((label, main_reduced, pr_reduced))
else:
print(f" [FAIL] {label}")
print(f" main: {main_reduced}")
print(f" pr: {pr_reduced}")
failures.append(label)
print()
if errors:
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
for e in errors:
print(f" - {e}")
if failures:
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
for f in failures:
print(f" - {f}")
if intentional_diffs:
print(
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
f"fallback_edge → plugin dispatch when a plugin is registered."
)
if failures or errors:
return 1
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Make tests/plugins/video_gen a package."""
+342
View File
@@ -0,0 +1,342 @@
"""Tests for the FAL video gen plugin — family routing, payload shape."""
from __future__ import annotations
import pytest
from agent import video_gen_registry
@pytest.fixture(autouse=True)
def _reset_registry():
video_gen_registry._reset_for_tests()
yield
video_gen_registry._reset_for_tests()
def test_fal_provider_registers():
from plugins.video_gen.fal import FALVideoGenProvider, DEFAULT_MODEL
provider = FALVideoGenProvider()
video_gen_registry.register_provider(provider)
assert video_gen_registry.get_provider("fal") is provider
assert provider.display_name == "FAL"
# DEFAULT_MODEL is the cheap-tier default
assert provider.default_model() == DEFAULT_MODEL
assert DEFAULT_MODEL in {"pixverse-v6", "ltx-2.3"}
def test_fal_family_catalog():
"""Each family declares both endpoints. The catalog covers the
cheap + premium tiers Teknium listed."""
from plugins.video_gen.fal import FAL_FAMILIES
expected = {
# cheap
"ltx-2.3", "pixverse-v6",
# premium
"veo3.1", "seedance-2.0", "kling-v3-4k", "happy-horse",
}
assert expected.issubset(set(FAL_FAMILIES.keys())), (
f"missing families: {expected - set(FAL_FAMILIES.keys())}"
)
for fid, meta in FAL_FAMILIES.items():
assert meta.get("text_endpoint"), f"{fid} missing text_endpoint"
assert meta.get("image_endpoint"), f"{fid} missing image_endpoint"
assert meta["text_endpoint"] != meta["image_endpoint"]
assert meta.get("tier") in {"cheap", "premium"}, (
f"{fid} has invalid tier"
)
def test_kling_4k_uses_start_image_url():
"""Kling v3 4K's image-to-video endpoint expects start_image_url,
not image_url. The family must declare image_param_key='start_image_url'."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["kling-v3-4k"]
assert meta.get("image_param_key") == "start_image_url"
payload = _build_payload(
meta,
prompt="x",
image_url="https://example.com/i.png",
duration=5,
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=None,
seed=None,
)
assert payload.get("start_image_url") == "https://example.com/i.png"
assert "image_url" not in payload
def test_fal_list_models_advertises_both_modalities():
from plugins.video_gen.fal import FALVideoGenProvider
models = FALVideoGenProvider().list_models()
for m in models:
assert set(m["modalities"]) == {"text", "image"}, (
f"{m['id']} doesn't advertise both modalities — every family "
f"should have t2v + i2v"
)
def test_fal_unavailable_without_key(monkeypatch):
from plugins.video_gen.fal import FALVideoGenProvider
from plugins.video_gen import fal as fal_plugin
monkeypatch.delenv("FAL_KEY", raising=False)
# Also ensure managed gateway is unavailable
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
assert FALVideoGenProvider().is_available() is False
def test_fal_generate_requires_fal_key(monkeypatch):
from plugins.video_gen.fal import FALVideoGenProvider
from plugins.video_gen import fal as fal_plugin
monkeypatch.delenv("FAL_KEY", raising=False)
# Also ensure managed gateway is unavailable
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
result = FALVideoGenProvider().generate("a happy dog")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_fal_available_via_gateway(monkeypatch):
from plugins.video_gen.fal import FALVideoGenProvider
from plugins.video_gen import fal as fal_plugin
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(
fal_plugin,
"_resolve_managed_fal_video_gateway",
lambda: object(), # truthy sentinel — gateway is available
)
assert FALVideoGenProvider().is_available() is True
class TestFamilyRouting:
"""The headline behavior: image_url presence picks the endpoint."""
@pytest.fixture
def with_fake_fal(self, monkeypatch):
"""Stub fal_client.submit to capture which endpoint we hit."""
import sys
import types
captured = {"endpoint": None, "arguments": None}
class FakeHandle:
def get(self):
return {"video": {"url": "https://fake/out.mp4"}}
fake = types.ModuleType("fal_client")
def _submit(endpoint, arguments=None, headers=None):
captured["endpoint"] = endpoint
captured["arguments"] = arguments
return FakeHandle()
fake.submit = _submit # type: ignore
monkeypatch.setitem(sys.modules, "fal_client", fake)
# Reset the lazy global so it picks up our stub
from plugins.video_gen import fal as fal_plugin
fal_plugin._fal_client = None
# Also reset the managed client cache
fal_plugin._managed_fal_video_client = None
fal_plugin._managed_fal_video_client_config = None
monkeypatch.setenv("FAL_KEY", "test")
# Force direct mode — no managed gateway
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
return captured
def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"a dog running",
model="pixverse-v6",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/text-to-video"
assert result["modality"] == "text"
assert with_fake_fal["arguments"]["prompt"] == "a dog running"
assert "image_url" not in with_fake_fal["arguments"]
def test_image_to_video_routes_to_image_endpoint(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"animate this dog",
model="pixverse-v6",
image_url="https://example.com/dog.png",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/image-to-video"
assert result["modality"] == "image"
assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png"
def test_default_family_text_routing(self, with_fake_fal):
"""No model arg → DEFAULT_MODEL → text-to-video endpoint."""
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
result = FALVideoGenProvider().generate("a dog")
assert result["success"] is True
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"]
assert with_fake_fal["endpoint"] == expected_endpoint
def test_default_family_image_routing(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
result = FALVideoGenProvider().generate(
"animate this",
image_url="https://example.com/i.png",
)
assert result["success"] is True
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["image_endpoint"]
assert with_fake_fal["endpoint"] == expected_endpoint
def test_unknown_family_falls_back_to_default(self, with_fake_fal):
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
result = FALVideoGenProvider().generate(
"x",
model="not-a-real-family",
)
assert result["success"] is True
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"]
assert with_fake_fal["endpoint"] == expected_endpoint
def test_premium_seedance_routing(self, with_fake_fal):
"""Sanity check the premium-tier seedance routes correctly."""
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"a dog",
model="seedance-2.0",
image_url="https://example.com/dog.png",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "bytedance/seedance-2.0/image-to-video"
# Seedance uses regular image_url (not start_image_url)
assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png"
def test_kling_4k_remaps_image_param(self, with_fake_fal):
"""Kling v3 4K image-to-video receives start_image_url, not image_url."""
from plugins.video_gen.fal import FALVideoGenProvider
result = FALVideoGenProvider().generate(
"x",
model="kling-v3-4k",
image_url="https://example.com/frame.png",
)
assert result["success"] is True
assert with_fake_fal["endpoint"] == "fal-ai/kling-video/v3/4k/image-to-video"
assert with_fake_fal["arguments"].get("start_image_url") == "https://example.com/frame.png"
assert "image_url" not in with_fake_fal["arguments"]
class TestPayloadBuilder:
def test_drops_unsupported_keys(self):
"""Veo enum-clamps duration, supports aspect+resolution+audio+neg."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["veo3.1"]
p = _build_payload(
meta,
prompt="x",
image_url=None,
duration=12, # not in enum (4,6,8) — snap to 8
aspect_ratio="16:9",
resolution="720p",
negative_prompt="ugly",
audio=True,
seed=42,
)
assert p["prompt"] == "x"
assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API
assert p["aspect_ratio"] == "16:9"
assert p["resolution"] == "720p"
assert p["generate_audio"] is True
assert p["negative_prompt"] == "ugly"
assert p["seed"] == 42
def test_pixverse_range_clamps_correctly(self):
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["pixverse-v6"]
p = _build_payload(
meta,
prompt="x",
image_url="https://i.png",
duration=99, # over max → 15
aspect_ratio="16:9",
resolution="540p",
negative_prompt=None,
audio=None,
seed=None,
)
assert p["duration"] == "15"
def test_kling_4k_clamps_below_min(self):
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["kling-v3-4k"]
p = _build_payload(
meta,
prompt="x",
image_url="https://i.png",
duration=1, # below min (3) → 3
aspect_ratio="16:9",
resolution="720p",
negative_prompt=None,
audio=None,
seed=None,
)
assert p["duration"] == "3"
def test_ltx_omits_duration_aspect_resolution(self):
"""LTX 2.3 doesn't declare duration/aspect/resolution enums —
the payload should NOT include those keys (let FAL default)."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["ltx-2.3"]
p = _build_payload(
meta,
prompt="x",
image_url=None,
duration=8,
aspect_ratio="16:9",
resolution="720p",
negative_prompt="ugly",
audio=True,
seed=None,
)
assert "duration" not in p
assert "aspect_ratio" not in p
assert "resolution" not in p
# But audio + negative are advertised
assert p["generate_audio"] is True
assert p["negative_prompt"] == "ugly"
def test_happy_horse_minimal_payload(self):
"""Happy Horse has sparse docs — payload should be minimal."""
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
meta = FAL_FAMILIES["happy-horse"]
p = _build_payload(
meta,
prompt="a horse galloping",
image_url=None,
duration=8,
aspect_ratio="16:9",
resolution="720p",
negative_prompt="watermark",
audio=True,
seed=None,
)
# Only prompt — no payload bloat for fields we can't verify
assert p == {"prompt": "a horse galloping"}
+226
View File
@@ -0,0 +1,226 @@
"""Smoke tests for the xAI video gen plugin — load & register surface."""
from __future__ import annotations
import pytest
from agent import video_gen_registry
@pytest.fixture(autouse=True)
def _reset_registry():
video_gen_registry._reset_for_tests()
yield
video_gen_registry._reset_for_tests()
def test_xai_provider_registers():
from plugins.video_gen.xai import XAIVideoGenProvider
provider = XAIVideoGenProvider()
video_gen_registry.register_provider(provider)
assert video_gen_registry.get_provider("xai") is provider
assert provider.display_name == "xAI"
assert provider.default_model() == "grok-imagine-video"
def test_xai_provider_lists_text_and_current_image_video_models():
from plugins.video_gen.xai import XAIVideoGenProvider
models = XAIVideoGenProvider().list_models()
ids = [model["id"] for model in models]
assert ids[0] == "grok-imagine-video"
assert ids[1] == "grok-imagine-video-1.5"
assert models[1]["modalities"] == ["image"]
assert "aliases" not in models[1]
def test_xai_routes_default_models_by_modality():
from plugins.video_gen.xai import _resolve_model_for_modality
assert _resolve_model_for_modality(
"grok-imagine-video",
modality="text",
explicit_model=False,
) == "grok-imagine-video"
assert _resolve_model_for_modality(
"grok-imagine-video",
modality="image",
explicit_model=False,
) == "grok-imagine-video-1.5"
assert _resolve_model_for_modality(
"grok-imagine-video-1.5-preview",
modality="text",
explicit_model=False,
) == "grok-imagine-video"
assert _resolve_model_for_modality(
"grok-imagine-video-1.5-preview",
modality="text",
explicit_model=True,
) == "grok-imagine-video-1.5-preview"
def test_xai_capabilities_keep_generate_surface_only():
from plugins.video_gen.xai import XAIVideoGenProvider
caps = XAIVideoGenProvider().capabilities()
assert caps["modalities"] == ["text", "image"]
assert "operations" not in caps
assert caps["max_reference_images"] == 7
def test_xai_unavailable_without_key(monkeypatch):
from plugins.video_gen.xai import XAIVideoGenProvider
monkeypatch.delenv("XAI_API_KEY", raising=False)
assert XAIVideoGenProvider().is_available() is False
def test_xai_generate_requires_xai_key(monkeypatch):
from plugins.video_gen.xai import XAIVideoGenProvider
monkeypatch.delenv("XAI_API_KEY", raising=False)
result = XAIVideoGenProvider().generate("a happy dog")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_xai_available_with_oauth_only(monkeypatch):
"""The plugin must honour xAI Grok OAuth credentials, not just
XAI_API_KEY. Otherwise the agent's tool-availability check filters
``video_generate`` out of the toolbelt and the agent silently falls
back to whatever skill advertises video generation (e.g. comfyui).
"""
import plugins.video_gen.xai as xai_plugin
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setattr(
"tools.xai_http.resolve_xai_http_credentials",
lambda: {
"provider": "xai-oauth",
"api_key": "oauth-bearer-token",
"base_url": "https://api.x.ai/v1",
},
)
assert xai_plugin.XAIVideoGenProvider().is_available() is True
def test_xai_resolved_credentials_threaded_through_request(monkeypatch):
"""OAuth-resolved creds must reach the HTTP layer — bug class where
``is_available()`` says yes but the request still hits with no key.
"""
import plugins.video_gen.xai as xai_plugin
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setattr(
"tools.xai_http.resolve_xai_http_credentials",
lambda: {
"provider": "xai-oauth",
"api_key": "oauth-bearer-token",
"base_url": "https://api.x.ai/v1",
},
)
api_key, base_url = xai_plugin._resolve_xai_credentials()
assert api_key == "oauth-bearer-token"
assert base_url == "https://api.x.ai/v1"
headers = xai_plugin._xai_headers(api_key)
assert headers["Authorization"] == "Bearer oauth-bearer-token"
def test_xai_no_operation_kwarg():
"""The ABC's generate() signature no longer accepts 'operation'.
Passing it through **kwargs should be ignored (forward-compat)."""
from plugins.video_gen.xai import XAIVideoGenProvider
# We're not actually hitting the network — just verify the call
# doesn't TypeError on the unexpected kwarg.
# Will fail with auth_required (no XAI_API_KEY), but should NOT
# fail with TypeError.
result = XAIVideoGenProvider().generate("x", operation="generate")
assert result["success"] is False
# auth_required, NOT some signature error
assert result["error_type"] in {"auth_required", "api_error"}
def test_xai_video_output_urls_prefers_stored_public_url():
from plugins.video_gen.xai import _xai_video_output_urls
public_url, temporary, stored = _xai_video_output_urls({
"url": "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4",
"file_output": {
"public_url": "https://files-cdn.x.ai/token/file_abc.mp4",
"file_id": "file_abc",
},
})
assert public_url == "https://files-cdn.x.ai/token/file_abc.mp4"
assert stored == "https://files-cdn.x.ai/token/file_abc.mp4"
assert temporary == "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4"
@pytest.mark.asyncio
async def test_video_input_from_public_url_uses_url_field():
from plugins.video_gen.xai import _video_input_from_public_url
url = "https://files-cdn.x.ai/kRQVP6PRQlioVAUNC3GAdg/file_1faca9c3-9411-46ad-bb41-b9b8527789e6.mp4"
result = await _video_input_from_public_url(
url,
api_key="test-key",
base_url="https://api.x.ai/v1",
)
assert result == {"url": url}
def test_video_input_from_public_url_rejects_bare_file_id():
import asyncio
from plugins.video_gen.xai import _video_input_from_public_url
result = asyncio.run(
_video_input_from_public_url(
"file_1faca9c3-9411-46ad-bb41-b9b8527789e6",
api_key="test-key",
base_url="https://api.x.ai/v1",
)
)
assert result is None
def test_xai_video_image_input_blocks_credential_store_symlink(tmp_path, monkeypatch):
from plugins.video_gen.xai import _image_ref_to_xai_input
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
image_link = hermes_home / "leak.png"
try:
image_link.symlink_to(auth_json)
except OSError as exc:
pytest.skip(f"symlink unavailable on this platform: {exc}")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
_image_ref_to_xai_input(str(image_link))
def test_xai_video_file_input_blocks_credential_store_symlink(tmp_path, monkeypatch):
from plugins.video_gen.xai import _video_ref_to_xai_url
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
video_link = hermes_home / "leak.mp4"
try:
video_link.symlink_to(auth_json)
except OSError as exc:
pytest.skip(f"symlink unavailable on this platform: {exc}")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
_video_ref_to_xai_url(str(video_link))
@@ -0,0 +1,215 @@
"""Integration tests for the xAI video gen plugin's simplified surface.
xAI exposes only text-to-video and image-to-video through the unified
``video_generate`` tool. We assert the endpoint hit and the payload shape
because routing is the part most likely to break silently.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any, Dict, List, Optional
import pytest
from agent import video_gen_registry
@pytest.fixture(autouse=True)
def _reset_registry():
video_gen_registry._reset_for_tests()
yield
video_gen_registry._reset_for_tests()
class _FakeResponse:
def __init__(self, status: int = 200, payload: Optional[Dict[str, Any]] = None):
self.status_code = status
self._payload = payload or {}
self.text = json.dumps(self._payload)
def raise_for_status(self):
if self.status_code >= 400:
import httpx
raise httpx.HTTPStatusError("err", request=None, response=self) # type: ignore
def json(self):
return self._payload
class _FakeAsyncClient:
def __init__(self):
self.posts: List[Dict[str, Any]] = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def post(self, url, headers=None, json=None, timeout=None):
self.posts.append({"url": url, "json": json})
return _FakeResponse(200, {"request_id": "req-123"})
async def get(self, url, headers=None, timeout=None):
return _FakeResponse(200, {
"status": "done",
"video": {"url": "https://xai-cdn/out.mp4", "duration": 8},
"model": self.posts[-1]["json"]["model"],
})
@pytest.fixture
def xai_provider(monkeypatch):
monkeypatch.setenv("XAI_API_KEY", "test-key")
import plugins.video_gen.xai as xai_plugin
captured: Dict[str, _FakeAsyncClient] = {}
def _client_factory():
captured["client"] = _FakeAsyncClient()
return captured["client"]
monkeypatch.setattr(xai_plugin.httpx, "AsyncClient", _client_factory)
async def _no_sleep(*a, **k):
return None
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
provider = xai_plugin.XAIVideoGenProvider()
return provider, captured
def _last_post(captured) -> Dict[str, Any]:
return captured["client"].posts[-1]
class TestXAIEndpoint:
"""xAI uses one endpoint — ``/videos/generations`` — for both modes."""
def test_text_to_video_hits_generations(self, xai_provider):
provider, captured = xai_provider
result = provider.generate("a dog on a skateboard")
assert result["success"] is True
assert _last_post(captured)["url"].endswith("/videos/generations")
assert result["modality"] == "text"
def test_image_to_video_hits_generations(self, xai_provider):
provider, captured = xai_provider
result = provider.generate(
"animate this",
image_url="https://example.com/cat.png",
)
assert result["success"] is True
assert _last_post(captured)["url"].endswith("/videos/generations")
assert result["modality"] == "image"
class TestXAIPayload:
def test_text_payload_has_no_image_field(self, xai_provider):
provider, captured = xai_provider
provider.generate("a dog at sunset")
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video"
assert payload["prompt"] == "a dog at sunset"
assert "image" not in payload
assert "reference_images" not in payload
def test_image_payload_has_image_field(self, xai_provider):
provider, captured = xai_provider
provider.generate("animate this", image_url="https://example.com/cat.png")
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video-1.5"
assert payload["image"] == {"url": "https://example.com/cat.png"}
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
provider, captured = xai_provider
image_path = tmp_path / "frame.png"
image_path.write_bytes(b"\x89PNG\r\n\x1a\nfake")
provider.generate("animate this", image_url=str(image_path))
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video-1.5"
assert payload["image"]["url"].startswith("data:image/png;base64,")
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
provider, captured = xai_provider
provider.generate(
"animate this",
image_url="https://example.com/cat.png",
model="grok-imagine-video",
_model_override_explicit=True,
)
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video"
def test_reference_images_payload(self, xai_provider):
provider, captured = xai_provider
provider.generate(
"keep this character",
reference_image_urls=[
"https://example.com/a.png",
"https://example.com/b.png",
],
)
payload = _last_post(captured)["json"]
assert payload["reference_images"] == [
{"url": "https://example.com/a.png"},
{"url": "https://example.com/b.png"},
]
class TestXAIValidation:
def test_missing_prompt_rejects(self, xai_provider):
provider, captured = xai_provider
result = provider.generate("")
assert result["success"] is False
assert result["error_type"] == "missing_prompt"
# Never hit the network
assert "client" not in captured or not captured["client"].posts
def test_image_plus_refs_rejects(self, xai_provider):
provider, captured = xai_provider
result = provider.generate(
"x",
image_url="https://example.com/i.png",
reference_image_urls=["https://example.com/r.png"],
)
assert result["success"] is False
assert result["error_type"] == "conflicting_inputs"
assert "client" not in captured or not captured["client"].posts
def test_too_many_references_rejects(self, xai_provider):
provider, captured = xai_provider
result = provider.generate(
"x",
reference_image_urls=[f"https://example.com/r{i}.png" for i in range(8)],
)
assert result["success"] is False
assert result["error_type"] == "too_many_references"
class TestXAIClamping:
def test_duration_clamped_to_15(self, xai_provider):
provider, captured = xai_provider
provider.generate("x", duration=30)
assert _last_post(captured)["json"]["duration"] == 15
def test_duration_clamped_when_refs_present(self, xai_provider):
provider, captured = xai_provider
provider.generate(
"x",
duration=15,
reference_image_urls=["https://example.com/r.png"],
)
# refs present caps to 10
assert _last_post(captured)["json"]["duration"] == 10
def test_invalid_aspect_ratio_soft_clamps(self, xai_provider):
provider, captured = xai_provider
provider.generate("x", aspect_ratio="21:9")
assert _last_post(captured)["json"]["aspect_ratio"] == "16:9"
View File
@@ -0,0 +1,498 @@
"""Plugin-side tests for the web search provider migration (PR #25182).
Covers:
- All eight bundled plugins (brave-free, ddgs, searxng, exa, parallel,
tavily, firecrawl, xai) instantiate and self-report the expected
capabilities + ABC-derived defaults.
- Each plugin's ``is_available()`` correctly reflects env-var presence.
- The web_search_registry resolves an active provider in the documented
scenarios (explicit config wins ignoring availability, fallback walks
legacy preference filtered by availability, unknown name falls back).
- Plugin response shapes match the legacy bit-for-bit contract.
Per the dev skill: these tests use *real* imports from the plugin
modules — no mocking of provider classes themselves — so the test
catches drift in the ABC interface, the registry, and the plugin
glue layer simultaneously.
"""
from __future__ import annotations
import asyncio
import inspect
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Strip every web-provider env var so is_available() returns False."""
for k in (
"BRAVE_SEARCH_API_KEY",
"SEARXNG_URL",
"TAVILY_API_KEY",
"TAVILY_BASE_URL",
"EXA_API_KEY",
"PARALLEL_API_KEY",
"PARALLEL_SEARCH_MODE",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_USER_TOKEN",
"XAI_API_KEY",
):
monkeypatch.delenv(k, raising=False)
def _ensure_plugins_loaded() -> None:
"""Idempotently load plugins so the registry is populated."""
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
# ---------------------------------------------------------------------------
# Per-plugin discovery + capability flags
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Each test starts with a clean web-provider env."""
_clear_web_env(monkeypatch)
class TestBundledPluginsRegister:
"""All eight bundled web plugins discover and register correctly."""
def test_all_seven_plugins_present_in_registry(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import list_providers
names = sorted(p.name for p in list_providers())
assert names == [
"brave-free",
"ddgs",
"exa",
"firecrawl",
"parallel",
"searxng",
"tavily",
"xai",
]
@pytest.mark.parametrize(
"plugin_name,expected_search,expected_extract",
[
("brave-free", True, False),
("ddgs", True, False),
("searxng", True, False),
("exa", True, True),
("parallel", True, True),
("tavily", True, True),
("firecrawl", True, True),
# xai: search-only via Grok's agentic web_search tool.
("xai", True, False),
],
)
def test_capability_flags_match_spec(
self,
plugin_name: str,
expected_search: bool,
expected_extract: bool,
) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None, f"plugin {plugin_name!r} not registered"
assert provider.supports_search() is expected_search
assert provider.supports_extract() is expected_extract
@pytest.mark.parametrize(
"plugin_name",
["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"],
)
def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None
assert provider.name == plugin_name
assert provider.display_name # any non-empty string
@pytest.mark.parametrize(
"plugin_name",
["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"],
)
def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
"""``get_setup_schema()`` returns a dict the picker can consume."""
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None
schema = provider.get_setup_schema()
assert isinstance(schema, dict)
assert "name" in schema
assert "env_vars" in schema
# ---------------------------------------------------------------------------
# is_available() behavior
# ---------------------------------------------------------------------------
class TestIsAvailable:
"""Each plugin's ``is_available()`` returns False without env config."""
def test_brave_free_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("brave-free")
assert p is not None
assert p.is_available() is False # no BRAVE_SEARCH_API_KEY
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "real")
assert p.is_available() is True
def test_searxng_requires_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("searxng")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080")
assert p.is_available() is True
def test_tavily_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("tavily")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("TAVILY_API_KEY", "real")
assert p.is_available() is True
def test_exa_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("exa")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("EXA_API_KEY", "real")
assert p.is_available() is True
def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("parallel")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("PARALLEL_API_KEY", "real")
assert p.is_available() is True
def test_firecrawl_requires_either_key_or_url(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("firecrawl")
assert p is not None
assert p.is_available() is False
# Either FIRECRAWL_API_KEY or FIRECRAWL_API_URL lights it up.
monkeypatch.setenv("FIRECRAWL_API_KEY", "real")
assert p.is_available() is True
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
assert p.is_available() is True
def test_ddgs_always_available_when_package_importable(self) -> None:
"""DDGS is the always-on fallback — no API key required.
It may report unavailable if the ``ddgs`` package itself isn't
installed in the env (legitimate — the plugin's post_setup hook
triggers pip install on first selection). We only assert that
is_available() doesn't raise.
"""
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("ddgs")
assert p is not None
# Truthy or falsy, just must not raise.
_ = bool(p.is_available())
def test_xai_requires_api_key_or_oauth(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""xAI needs XAI_API_KEY or OAuth tokens in auth.json."""
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("xai")
assert p is not None
assert p.is_available() is False # no XAI_API_KEY, no auth.json
monkeypatch.setenv("XAI_API_KEY", "real")
assert p.is_available() is True
# ---------------------------------------------------------------------------
# Registry resolution semantics (Option B — conservative smart fallback)
# ---------------------------------------------------------------------------
class TestRegistryResolution:
"""``_resolve()`` follows explicit-config + availability-filtered fallback."""
def test_explicit_configured_provider_returned_even_when_unavailable(
self,
) -> None:
"""Explicit ``web.search_backend`` wins regardless of is_available().
Without availability filtering on the explicit path, the dispatcher
would silently switch backends; with this check the dispatcher
surfaces a precise "FOO_API_KEY is not set" error instead.
"""
_ensure_plugins_loaded()
from agent.web_search_registry import _resolve
# No BRAVE_SEARCH_API_KEY (fixture cleared it).
result = _resolve("brave-free", capability="search")
assert result is not None
assert result.name == "brave-free"
# Confirm it's the unavailable one — dispatcher will surface
# a typed credential-missing error to the caller.
assert result.is_available() is False
def test_unknown_configured_name_falls_back_to_available_provider(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Typo / uninstalled plugin → walk legacy preference, pick available."""
_ensure_plugins_loaded()
from agent.web_search_registry import _resolve
monkeypatch.setenv("EXA_API_KEY", "real")
result = _resolve("not-a-real-provider", capability="search")
# Either ddgs (no-key fallback) or exa (the only available
# premium provider) — both are valid. The point is the unknown
# name shouldn't return None when SOMETHING is available.
assert result is not None
assert result.is_available() is True
def test_explicit_search_only_provider_for_extract_falls_back(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Asking for extract via a search-only backend → fall back.
``brave-free`` is search-only (``supports_extract() is False``).
When the registry resolves it for an extract capability, the
explicit-config branch rejects it as capability-incompatible
and the fallback walk picks an extract-capable provider.
"""
_ensure_plugins_loaded()
from agent.web_search_registry import _resolve
monkeypatch.setenv("EXA_API_KEY", "real")
result = _resolve("brave-free", capability="extract")
# Should land on exa (only extract-capable available provider).
assert result is not None
assert result.supports_extract() is True
assert result.is_available() is True
def test_no_config_no_credentials_returns_none(
self,
) -> None:
"""No backend configured AND no available providers → typically None.
``ddgs`` is the no-credential fallback; if its ``ddgs`` Python
package is installed in the test env, ddgs will be picked.
Otherwise the resolver returns None. Either outcome is correct.
"""
_ensure_plugins_loaded()
from agent.web_search_registry import _resolve
result = _resolve(None, capability="search")
if result is not None:
# The only no-credential provider is ddgs; anything else
# means an env var leaked in.
assert result.is_available() is True
# ---------------------------------------------------------------------------
# Sync-vs-async extract detection
# ---------------------------------------------------------------------------
class TestAsyncExtractDispatch:
"""The dispatcher detects async vs sync extract methods correctly."""
def test_parallel_extract_is_async(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("parallel")
assert p is not None
assert inspect.iscoroutinefunction(p.extract) is True
def test_firecrawl_extract_is_async(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("firecrawl")
assert p is not None
assert inspect.iscoroutinefunction(p.extract) is True
def test_exa_extract_is_sync(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("exa")
assert p is not None
assert inspect.iscoroutinefunction(p.extract) is False
def test_tavily_extract_is_sync(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("tavily")
assert p is not None
assert inspect.iscoroutinefunction(p.extract) is False
# ---------------------------------------------------------------------------
# Error response shape (preserved bit-for-bit from legacy)
# ---------------------------------------------------------------------------
class TestErrorResponseShapes:
"""When credentials are missing, plugins return typed errors, not raises."""
def test_brave_free_returns_error_dict_when_unconfigured(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("brave-free")
assert p is not None
result = p.search("test", limit=5)
assert isinstance(result, dict)
assert result.get("success") is False
assert "error" in result
def test_searxng_returns_error_dict_when_unconfigured(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("searxng")
assert p is not None
result = p.search("test", limit=5)
assert isinstance(result, dict)
assert result.get("success") is False
assert "error" in result
def test_exa_returns_error_dict_when_unconfigured(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("exa")
assert p is not None
result = p.search("test", limit=5)
assert isinstance(result, dict)
assert result.get("success") is False
assert "error" in result
def test_tavily_returns_error_dict_when_unconfigured(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("tavily")
assert p is not None
result = p.search("test", limit=5)
assert isinstance(result, dict)
assert result.get("success") is False
assert "error" in result
def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("parallel")
assert p is not None
result = asyncio.run(p.extract(["https://example.com"]))
assert isinstance(result, list)
assert len(result) == 1
assert "error" in result[0]
assert result[0]["url"] == "https://example.com"
def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None:
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("firecrawl")
assert p is not None
# firecrawl extract returns [] when the website-policy gate rejects
# the URL, or a per-URL error dict when the gate passes but the
# firecrawl client fails. Use a URL the policy allows to make sure
# we hit the credential-missing path.
result = asyncio.run(p.extract(["https://example.com"]))
assert isinstance(result, list)
if result: # if anything came back, it should be an error entry
assert "error" in result[0]
def test_firecrawl_config_error_points_paid_users_to_nous_subscription(self, monkeypatch):
from plugins.web.firecrawl import provider as firecrawl_provider
monkeypatch.setattr(
"tools.web_tools.managed_nous_tools_enabled",
lambda: True,
raising=False,
)
with pytest.raises(ValueError) as exc_info:
firecrawl_provider._raise_web_backend_configuration_error()
message = str(exc_info.value)
assert "With your Nous subscription you can also use the Tool Gateway" in message
assert "select Nous Subscription as the web provider" in message
assert "managed Firecrawl web tools is unavailable" not in message
def test_firecrawl_config_error_uses_entitlement_message_when_not_paid(self, monkeypatch):
from plugins.web.firecrawl import provider as firecrawl_provider
monkeypatch.setattr(
"tools.web_tools.managed_nous_tools_enabled",
lambda: False,
raising=False,
)
monkeypatch.setattr(
"tools.web_tools.nous_tool_gateway_unavailable_message",
lambda capability: f"{capability} denied by test entitlement.",
raising=False,
)
with pytest.raises(ValueError) as exc_info:
firecrawl_provider._raise_web_backend_configuration_error()
assert "managed Firecrawl web tools denied by test entitlement" in str(exc_info.value)
def test_xai_search_returns_error_dict_when_unconfigured(self) -> None:
"""xAI returns a typed error dict (no XAI_API_KEY)."""
_ensure_plugins_loaded()
from agent.web_search_registry import get_provider
p = get_provider("xai")
assert p is not None
result = p.search("test", limit=5)
assert isinstance(result, dict)
assert result.get("success") is False
assert "error" in result