Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 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,297 @@
"""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", "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 carries a post-setup hook so the
# picker can auto-install its CLI dependency on selection.
assert schema.get("post_setup")
def test_browser_use_hidden_from_picker(self) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
provider = get_provider("browser-use")
assert provider is not None
assert provider.get_setup_schema() is None
@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_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_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"
# ---------------------------------------------------------------------------
# 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 == ["browserbase", "firecrawl"]
@@ -0,0 +1,221 @@
"""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_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_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_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,175 @@
"""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_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_empty(self, drain):
p = drain.DrainSecretProvider(secret=_strong_secret())
assert p.verify_token(token="") is None
def test_construction_rejects_weak_secret(self, drain):
with pytest.raises(ValueError):
drain.DrainSecretProvider(secret="weak")
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,665 @@
"""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_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_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_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}"
)
# ---------------------------------------------------------------------------
# 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_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_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")
# ---------------------------------------------------------------------------
# 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_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_jwks_client_sends_explicit_http_headers(self, provider):
"""Constructor-contract regression: the JWKS fetch must send an
explicit Accept + User-Agent so it isn't blocked by the Portal WAF
(same fix as the self_hosted provider)."""
provider._jwks_client = None
with patch("jwt.PyJWKClient") as client_cls:
provider._get_jwks_client()
client_cls.assert_called_once_with(
provider._jwks_url,
cache_keys=True,
lifespan=nous_plugin._JWKS_CACHE_SECONDS,
headers={
"Accept": "application/json",
"User-Agent": "HermesAgent/1.0",
},
)
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_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_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_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_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_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
@@ -0,0 +1,156 @@
"""#94558 — a non-JWT bearer must not be reported as "Auth provider unreachable".
Hosted agents answered every opaque/peer bearer on the gated API with a fast
HTTP 503 ``{"detail": "Auth provider 'nous' unreachable"}`` while Portal was
perfectly healthy: ``NousDashboardAuthProvider._verify_jwt`` folded *every*
``PyJWKClient`` failure — including ``DecodeError('Not enough segments')`` for
a token that is not a JWT at all — into ``ProviderError``. Only a transport
failure fetching the JWKS is "unreachable"; anything else means "not my
token" (``verify_session`` -> None -> 401 / next provider).
Real ``NousDashboardAuthProvider`` + real ``SelfHostedOIDCProvider`` JWKS path,
a real local HTTP JWKS server (reachable case) or a closed port (unreachable),
and the real gated web_server app for the HTTP-level assertion.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import jwt
import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import (
InvalidCodeError,
ProviderError,
classify_jwks_lookup_error,
clear_providers,
register_provider,
)
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
import plugins.dashboard_auth.nous as nous_plugin
OPAQUE_PEER_KEY = "hk_live_opaque_peer_key_0123456789abcdef"
# Well-formed RS256 JWT header with an unknown kid, bogus payload/signature.
FOREIGN_KID_JWT = "eyJhbGciOiJSUzI1NiIsImtpZCI6Inp6eiJ9.e30.sig"
@pytest.fixture(scope="module")
def empty_jwks_server():
"""A reachable JWKS endpoint that knows no keys."""
class _H(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
self.send_response(200)
self.send_header("content-type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"keys": []}).encode())
def log_message(self, *a): # silence
pass
srv = HTTPServer(("127.0.0.1", 0), _H)
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
yield f"http://127.0.0.1:{srv.server_address[1]}"
srv.shutdown()
def _nous(portal_url: str) -> nous_plugin.NousDashboardAuthProvider:
return nous_plugin.NousDashboardAuthProvider(client_id="agent:test-instance", portal_url=portal_url)
# ── classifier ────────────────────────────────────────────────────────────
def test_classifier_maps_transport_failure_to_provider_error():
exc = jwt.PyJWKClientConnectionError("Fail to fetch data from the url")
assert isinstance(classify_jwks_lookup_error(exc), ProviderError)
@pytest.mark.parametrize(
"exc",
[
jwt.DecodeError("Not enough segments"),
jwt.PyJWKSetError("The JWK Set did not contain any keys"),
jwt.InvalidTokenError("bad"),
],
)
def test_classifier_maps_unverifiable_token_to_invalid_code(exc):
assert isinstance(classify_jwks_lookup_error(exc), InvalidCodeError)
def test_classifier_keeps_bare_jwk_client_error_as_provider_fault():
assert isinstance(classify_jwks_lookup_error(jwt.PyJWKClientError("weird JWKS shape")), ProviderError)
# ── Nous provider ─────────────────────────────────────────────────────────
def test_opaque_bearer_with_healthy_portal_is_not_unreachable(empty_jwks_server):
provider = _nous(empty_jwks_server)
assert provider.verify_session(access_token=OPAQUE_PEER_KEY) is None
def test_foreign_kid_jwt_with_healthy_portal_is_not_unreachable(empty_jwks_server):
provider = _nous(empty_jwks_server)
assert provider.verify_session(access_token=FOREIGN_KID_JWT) is None
def test_real_jwt_with_unreachable_portal_still_raises_provider_error():
provider = _nous("http://127.0.0.1:9") # discard port: connection refused
with pytest.raises(ProviderError):
provider.verify_session(access_token=FOREIGN_KID_JWT)
def test_opaque_bearer_with_unreachable_portal_is_still_just_not_ours():
"""No network call is even needed to know an opaque string is not our JWT."""
provider = _nous("http://127.0.0.1:9")
assert provider.verify_session(access_token=OPAQUE_PEER_KEY) is None
# ── self-hosted OIDC provider (sibling site of the same hunk) ──────────────
def test_self_hosted_provider_shares_the_classification(empty_jwks_server, monkeypatch):
import plugins.dashboard_auth.self_hosted as sh
provider = object.__new__(sh.SelfHostedOIDCProvider)
provider._jwks_client = None
provider._client_id = "hermes"
monkeypatch.setattr(
provider, "_get_discovery",
lambda: {"jwks_uri": f"{empty_jwks_server}/jwks", "issuer": empty_jwks_server},
)
with pytest.raises(InvalidCodeError):
provider._verify_id_token(OPAQUE_PEER_KEY)
# ── HTTP level: the gated API answers 401, not 503 ────────────────────────
@pytest.fixture
def _gated_nous(empty_jwks_server):
clear_providers()
prev = {k: getattr(web_server.app.state, k, None) for k in ("bound_host", "bound_port", "auth_required")}
web_server.app.state.bound_host = "agent.example.test"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
register_provider(_nous(empty_jwks_server))
yield TestClient(web_server.app, base_url="https://agent.example.test")
clear_providers()
for k, v in prev.items():
setattr(web_server.app.state, k, v)
def test_gated_api_rejects_opaque_bearer_with_401_not_503(_gated_nous):
r = _gated_nous.get("/api/auth/me", headers={"Authorization": f"Bearer {OPAQUE_PEER_KEY}"})
assert r.status_code != 503, r.text
assert r.status_code == 401
assert "unreachable" not in r.text.lower()
def test_gated_api_rejects_opaque_cookie_with_401_not_503(_gated_nous):
_gated_nous.cookies.set(SESSION_AT_COOKIE, OPAQUE_PEER_KEY)
r = _gated_nous.get("/api/auth/me")
assert r.status_code != 503, r.text
assert "unreachable" not in r.text.lower()
@@ -0,0 +1,729 @@
"""Tests for the bundled self-hosted OIDC dashboard-auth plugin.
Covers, by analogy with ``test_nous_provider.py``:
1. Plugin entry-point registration gating (env + config.yaml precedence).
2. ``start_login`` shape (PKCE/state, authorize URL parameters, OIDC discovery).
3. ``complete_login`` httpx-mocked happy path + error mapping (ID-token grant).
4. ``verify_session`` ID-token verification — RSA keypair, audience/issuer
pinning, standard OIDC claim mapping (sub/email/name/groups).
5. ``refresh_session`` rotation + error mapping, ``revoke_session`` (RFC 7009).
6. OIDC discovery: endpoint extraction, issuer pinning, https enforcement.
All HTTP is mocked: nothing here talks to a real IDP.
"""
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.self_hosted as oidc_plugin
from hermes_cli.dashboard_auth import (
InvalidCodeError,
LoginStart,
ProviderError,
RefreshExpiredError,
Session,
assert_protocol_compliance,
)
_ISSUER = "https://auth.example.com/application/o/hermes"
_CLIENT_ID = "hermes-dashboard"
_DISCOVERY_DOC = {
"issuer": _ISSUER,
"authorization_endpoint": f"{_ISSUER}/authorize",
"token_endpoint": f"{_ISSUER}/token",
"jwks_uri": f"{_ISSUER}/jwks",
"revocation_endpoint": f"{_ISSUER}/revoke",
}
# ---------------------------------------------------------------------------
# RSA keypair fixture (module-scope — keygen is slow)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def rsa_keypair() -> Dict[str, Any]:
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 — standard OIDC ID-token claims
# ---------------------------------------------------------------------------
def _mint_id_token(
rsa_keypair: Dict[str, Any],
*,
iss: str = _ISSUER,
aud: str = _CLIENT_ID,
sub: str = "usr_abc",
email: str | None = "alice@example.com",
name: str | None = "Alice Example",
groups: Any = None,
org_id: str | None = None,
ttl_seconds: int = 900,
extra_claims: Dict[str, Any] | None = None,
) -> str:
now = int(time.time())
claims: Dict[str, Any] = {
"iss": iss,
"aud": aud,
"sub": sub,
"iat": now,
"exp": now + ttl_seconds,
}
if email is not None:
claims["email"] = email
if name is not None:
claims["name"] = name
if groups is not None:
claims["groups"] = groups
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 _make_provider(
rsa_keypair,
*,
scopes: str | None = None,
client_secret: str | None = None,
auth_methods: Any = "__unset__",
):
"""Construct a provider with discovery + JWKS stubbed (no network).
``client_secret`` flips the provider into confidential mode. ``auth_methods``
overrides ``token_endpoint_auth_methods_supported`` in the seeded discovery
doc (pass a list, or ``None`` to omit the key entirely); left unset, the
discovery doc carries no auth-methods key (the absent-key default).
"""
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
if scopes is not None:
kwargs["scopes"] = scopes
if client_secret is not None:
kwargs["client_secret"] = client_secret
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
# Pre-seed discovery so nothing hits the network.
disco = dict(_DISCOVERY_DOC)
if auth_methods != "__unset__":
if auth_methods is None:
disco.pop("token_endpoint_auth_methods_supported", None)
else:
disco["token_endpoint_auth_methods_supported"] = auth_methods
p._discovery = disco
p._discovery_fetched_at = time.time()
# Patch the 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
p._jwks_client = fake_client
return p
def _mock_post(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
resp.json = MagicMock(side_effect=ValueError("not json"))
resp.headers = {"content-type": ctype}
return resp
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
class TestConstruction:
def test_protocol_compliance(self):
assert_protocol_compliance(oidc_plugin.SelfHostedOIDCProvider)
def test_strips_trailing_slash_from_issuer(self):
p = oidc_plugin.SelfHostedOIDCProvider(
issuer=_ISSUER + "/", client_id=_CLIENT_ID
)
assert p._issuer == _ISSUER
def test_requires_issuer(self):
with pytest.raises(ValueError, match="issuer"):
oidc_plugin.SelfHostedOIDCProvider(issuer="", client_id=_CLIENT_ID)
def test_rejects_non_https_issuer(self):
with pytest.raises(ProviderError, match="https"):
oidc_plugin.SelfHostedOIDCProvider(
issuer="http://auth.example.com", client_id=_CLIENT_ID
)
# ---------------------------------------------------------------------------
# OIDC discovery
# ---------------------------------------------------------------------------
class TestDiscovery:
def _provider(self):
return oidc_plugin.SelfHostedOIDCProvider(
issuer=_ISSUER, client_id=_CLIENT_ID
)
def _mock_get(self, status_code, body, *, ctype="application/json"):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.json = MagicMock(return_value=body)
resp.text = json.dumps(body) if isinstance(body, dict) else str(body)
resp.headers = {"content-type": ctype}
return resp
def test_fetches_and_caches(self):
p = self._provider()
mock_resp = self._mock_get(200, dict(_DISCOVERY_DOC))
with patch(
"plugins.dashboard_auth.self_hosted.httpx.get", return_value=mock_resp
) as mock_get:
disco1 = p._get_discovery()
disco2 = p._get_discovery()
assert disco1["token_endpoint"] == f"{_ISSUER}/token"
assert disco1["authorization_endpoint"] == f"{_ISSUER}/authorize"
assert disco1["jwks_uri"] == f"{_ISSUER}/jwks"
assert disco1["revocation_endpoint"] == f"{_ISSUER}/revoke"
# Cached — only one network call.
assert mock_get.call_count == 1
assert disco2 is disco1
# ---------------------------------------------------------------------------
# OIDC discovery against a REAL HTTP server that redirects (regression)
# ---------------------------------------------------------------------------
class TestDiscoveryRealRedirect:
"""Discovery must follow a 3xx on the .well-known GET.
The rest of the discovery suite mocks ``httpx.get`` with a canned 200, so
it cannot see httpx's ``follow_redirects=False`` default. Many real IDPs
answer the discovery GET with a redirect rather than a direct 200 —
Authentik canonicalises the ``.well-known`` path, and any IDP behind a
reverse proxy doing http→https upgrade redirects too. Before the fix the
bare 3xx (empty body) tripped the ``status != 200`` guard and surfaced as
``provider_unreachable`` → HTTP 503 (the symptom in the user report:
``curl -o`` writing zero bytes is exactly a redirect with no body).
This exercises the real httpx transport against a loopback server, so it
fails without ``follow_redirects=True`` and passes with it — a behaviour
contract, not a mock-shaped snapshot.
"""
def _serve(self, handler_cls):
import http.server
import socketserver
import threading
# Bind :0 so the OS picks a free port (parallel-runner safe).
httpd = socketserver.TCPServer(("127.0.0.1", 0), handler_cls)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
return httpd, port
# ---------------------------------------------------------------------------
# start_login
# ---------------------------------------------------------------------------
class TestStartLogin:
@pytest.fixture
def provider(self, rsa_keypair):
return _make_provider(rsa_keypair)
def test_returns_login_start(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.example/auth/callback"
)
assert isinstance(result, LoginStart)
def test_authorize_url_has_required_params(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.example/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"] == _CLIENT_ID
assert params["redirect_uri"] == "https://hermes.example/auth/callback"
assert params["scope"] == "openid profile email"
assert params["code_challenge_method"] == "S256"
assert "state" in params
assert "code_challenge" in params
def test_state_in_cookie_matches_url(self, provider):
result = provider.start_login(
redirect_uri="https://hermes.example/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"]
# ---------------------------------------------------------------------------
# complete_login
# ---------------------------------------------------------------------------
class TestCompleteLogin:
@pytest.fixture
def provider(self, rsa_keypair):
return _make_provider(rsa_keypair)
def test_happy_path_returns_session(self, provider, rsa_keypair):
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(
200,
{
"access_token": "opaque-at",
"id_token": id_token,
"token_type": "Bearer",
"refresh_token": "rt_initial",
},
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
):
session = provider.complete_login(
code="abc",
state="s",
code_verifier="vfy",
redirect_uri="https://hermes.example/auth/callback",
)
assert isinstance(session, Session)
assert session.user_id == "usr_abc"
assert session.provider == "self-hosted"
assert session.email == "alice@example.com"
assert session.display_name == "Alice Example"
# The verified ID token is stored in the access_token slot.
assert session.access_token == id_token
assert session.refresh_token == "rt_initial"
def test_tolerates_missing_refresh_token(self, provider, rsa_keypair):
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(
200, {"id_token": id_token, "token_type": "Bearer"}
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
):
session = provider.complete_login(
code="abc",
state="s",
code_verifier="vfy",
redirect_uri="https://hermes.example/auth/callback",
)
assert session.refresh_token == ""
def test_missing_id_token_raises(self, provider):
mock_resp = _mock_post(
200, {"access_token": "opaque", "token_type": "Bearer"}
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
):
with pytest.raises(ProviderError, match="id_token"):
provider.complete_login(
code="x",
state="s",
code_verifier="v",
redirect_uri="https://hermes.example/auth/callback",
)
def test_400_raises_invalid_code(self, provider):
mock_resp = _mock_post(400, {"error": "invalid_grant"})
with patch(
"plugins.dashboard_auth.self_hosted.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.example/auth/callback",
)
# ---------------------------------------------------------------------------
# Confidential client (client_secret) — token-endpoint client authentication
# ---------------------------------------------------------------------------
_GOOD_TOKEN_RESP_KEYS = {"token_type": "Bearer", "refresh_token": "rt_initial"}
def _decode_basic(header_value: str) -> tuple[str, str]:
"""Decode a ``Basic <b64>`` Authorization header back to (user, pass)."""
assert header_value.startswith("Basic ")
raw = base64.b64decode(header_value[len("Basic ") :]).decode("utf-8")
user, _, pw = raw.partition(":")
# client_id / secret are form-url-encoded before base64 (RFC 6749 §2.3.1).
return urllib.parse.unquote(user), urllib.parse.unquote(pw)
class TestConfidentialClient:
"""A configured ``client_secret`` authenticates the client at the token
endpoint (basic header or post body, auto-selected from discovery), while
PKCE is still sent. A public client (no secret) is byte-identical to the
pre-confidential-client behaviour — no secret anywhere, no auth header."""
def _complete(self, provider, rsa_keypair):
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(200, {"id_token": id_token, **_GOOD_TOKEN_RESP_KEYS})
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
) as mock_post:
provider.complete_login(
code="the-code",
state="s",
code_verifier="the-verifier",
redirect_uri="https://hermes.example/auth/callback",
)
_, kwargs = mock_post.call_args
return kwargs
# -- public client: nothing changes ------------------------------------
def test_public_client_sends_no_secret_or_auth_header(self, rsa_keypair):
# No client_secret configured → no Authorization header, no
# client_secret in the body. Pins the unchanged public-client contract.
provider = _make_provider(rsa_keypair) # public
kwargs = self._complete(provider, rsa_keypair)
assert "Authorization" not in kwargs["headers"]
assert "client_secret" not in kwargs["data"]
# PKCE still present.
assert kwargs["data"]["code_verifier"] == "the-verifier"
# Header is exactly the pre-feature value.
assert kwargs["headers"] == {"Accept": "application/json"}
# -- basic (default & explicit) ----------------------------------------
def test_confidential_defaults_to_basic_when_methods_absent(self, rsa_keypair):
# Discovery advertises no auth methods → OIDC default is Basic.
provider = _make_provider(
rsa_keypair, client_secret="s3cr3t", auth_methods=None
)
kwargs = self._complete(provider, rsa_keypair)
assert "client_secret" not in kwargs["data"] # not in body for basic
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
# PKCE still sent alongside the secret.
assert kwargs["data"]["code_verifier"] == "the-verifier"
# -- post --------------------------------------------------------------
# -- url-encoding of reserved chars ------------------------------------
def test_basic_url_encodes_reserved_chars_in_secret(self, rsa_keypair):
# A secret with ':' / '@' / space must round-trip through the Basic
# header exactly — these are exactly the chars that corrupt a naive
# "id:secret" concatenation.
tricky = "p@ss:wo rd/+="
provider = _make_provider(
rsa_keypair, client_secret=tricky, auth_methods=["client_secret_basic"]
)
kwargs = self._complete(provider, rsa_keypair)
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
assert user == _CLIENT_ID
assert pw == tricky
# -- blank secret is treated as public ---------------------------------
# -- refresh grant also authenticates ----------------------------------
def test_refresh_grant_authenticates_confidential_client(self, rsa_keypair):
provider = _make_provider(
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
)
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
) as mock_post:
provider.refresh_session(refresh_token="rt_old")
_, kwargs = mock_post.call_args
assert kwargs["data"]["grant_type"] == "refresh_token"
assert kwargs["data"]["client_secret"] == "s3cr3t"
# -- revocation also authenticates -------------------------------------
# -- the secret never appears in logs ----------------------------------
def test_secret_not_in_repr_or_log(self, rsa_keypair, caplog):
import logging
with caplog.at_level(logging.INFO):
provider = _make_provider(
rsa_keypair, client_secret="sup3r-s3cr3t", auth_methods=None
)
# The provider object's repr must not leak the secret.
assert "sup3r-s3cr3t" not in repr(provider)
assert "sup3r-s3cr3t" not in caplog.text
# ---------------------------------------------------------------------------
# verify_session
# ---------------------------------------------------------------------------
class TestVerifySession:
@pytest.fixture
def provider(self, rsa_keypair):
return _make_provider(rsa_keypair)
def test_expired_returns_none(self, provider, rsa_keypair):
token = _mint_id_token(rsa_keypair, ttl_seconds=-1)
assert provider.verify_session(access_token=token) is None
def test_wrong_audience_raises(self, provider, rsa_keypair):
token = _mint_id_token(rsa_keypair, aud="some-other-client")
with pytest.raises(ProviderError, match="verification failed"):
provider.verify_session(access_token=token)
def test_failure_message_surfaces_claims(self, provider, rsa_keypair):
token = _mint_id_token(rsa_keypair, iss="https://evil.example")
with pytest.raises(ProviderError) as excinfo:
provider.verify_session(access_token=token)
msg = str(excinfo.value)
assert "'https://evil.example'" in msg
assert f"'{_ISSUER}'" in msg
def test_jwks_unreachable_raises(self, provider, rsa_keypair):
token = _mint_id_token(rsa_keypair)
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)
def test_jwks_client_sends_explicit_http_headers(self):
provider = oidc_plugin.SelfHostedOIDCProvider(
issuer=_ISSUER, client_id=_CLIENT_ID
)
provider._discovery = dict(_DISCOVERY_DOC)
provider._discovery_fetched_at = time.time()
with patch("jwt.PyJWKClient") as client_cls:
provider._get_jwks_client()
client_cls.assert_called_once_with(
_DISCOVERY_DOC["jwks_uri"],
cache_keys=True,
lifespan=oidc_plugin._JWKS_CACHE_SECONDS,
headers={
"Accept": "application/json",
"User-Agent": "HermesAgent/1.0",
},
)
# ---------------------------------------------------------------------------
# refresh_session + revoke_session
# ---------------------------------------------------------------------------
class TestRefreshAndRevoke:
@pytest.fixture
def provider(self, rsa_keypair):
return _make_provider(rsa_keypair)
# ---------------------------------------------------------------------------
# Plugin entry point: env + config.yaml precedence
# ---------------------------------------------------------------------------
class TestPluginRegister:
@pytest.fixture(autouse=True)
def clear_env(self, monkeypatch):
for var in (
"HERMES_DASHBOARD_OIDC_ISSUER",
"HERMES_DASHBOARD_OIDC_CLIENT_ID",
"HERMES_DASHBOARD_OIDC_SCOPES",
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET",
):
monkeypatch.delenv(var, raising=False)
@pytest.fixture
def patch_config(self, monkeypatch):
def _set(oauth_block):
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_skips_when_unconfigured(self, patch_config):
patch_config(None)
ctx = MagicMock()
oidc_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "HERMES_DASHBOARD_OIDC_ISSUER" in oidc_plugin.LAST_SKIP_REASON
assert "self_hosted" in oidc_plugin.LAST_SKIP_REASON
def test_registers_from_env(self, patch_config, monkeypatch):
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
ctx = MagicMock()
oidc_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert isinstance(registered, oidc_plugin.SelfHostedOIDCProvider)
assert registered._issuer == _ISSUER
assert registered._client_id == _CLIENT_ID
assert registered._scopes == "openid profile email"
assert oidc_plugin.LAST_SKIP_REASON == ""
def test_env_overrides_config(self, patch_config, monkeypatch):
patch_config(
{
"self_hosted": {
"issuer": "https://config.example",
"client_id": "config-client",
}
}
)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._issuer == _ISSUER
assert registered._client_id == _CLIENT_ID
def test_config_load_failure_falls_through(self, monkeypatch):
def _broken():
raise OSError("unreadable")
monkeypatch.setattr("hermes_cli.config.load_config", _broken)
ctx = MagicMock()
oidc_plugin.register(ctx) # must not raise
ctx.register_dashboard_auth_provider.assert_not_called()
# -- client_secret wiring ----------------------------------------------
def test_secret_from_env(self, patch_config, monkeypatch):
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "env-secret"
def test_env_secret_overrides_config(self, patch_config, monkeypatch):
patch_config(
{
"self_hosted": {
"issuer": _ISSUER,
"client_id": _CLIENT_ID,
"client_secret": "cfg-secret",
}
}
)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "env-secret"
def test_empty_env_secret_does_not_shadow_config(self, patch_config, monkeypatch):
patch_config(
{
"self_hosted": {
"issuer": _ISSUER,
"client_id": _CLIENT_ID,
"client_secret": "cfg-secret",
}
}
)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "")
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "cfg-secret"
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,106 @@
"""Tests for the bundled DeepInfra image_gen plugin.
Invariants only — no snapshots of specific model ids. Most surface-level
contracts (network-failure → empty list, tag filtering, no-model error)
are covered by the shared tag-filter test in
``tests/hermes_cli/test_api_key_providers.py``; these two tests pin the
plugin-specific bits that wrapper doesn't reach.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import plugins.image_gen.deepinfra as deepinfra_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()
@pytest.fixture(autouse=True)
def _isolation(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import hermes_cli.models as _models_mod
monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {})
monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key")
yield
def test_list_models_filters_by_image_gen_tag(monkeypatch):
"""Plugin-side wiring: list_models() returns only ``image-gen``-tagged
catalog entries and surfaces pricing + default dims when present."""
import json
import hermes_cli.models as models
class _Resp:
def __enter__(self): return self
def __exit__(self, *a): return False
def read(self):
return json.dumps({"data": [
{"id": "vendor/chat", "metadata": {"tags": ["chat"]}},
{"id": "vendor/img", "metadata": {
"tags": ["image-gen"],
"pricing": {"per_image_unit": 0.005},
"default_width": 1024,
}},
]}).encode()
monkeypatch.setattr(
models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp()
)
rows = deepinfra_plugin.DeepInfraImageGenProvider().list_models()
ids = {row["id"] for row in rows}
assert ids == {"vendor/img"}
img = next(row for row in rows if row["id"] == "vendor/img")
assert "price" in img and img["default_width"] == 1024
def test_generate_calls_openai_sdk_with_deepinfra_base_url(monkeypatch):
"""Happy path: pinned model → openai SDK called with DeepInfra
base_url + Bearer key → b64 saved to cache."""
monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img")
captured: dict = {}
class _FakeImages:
def generate(self, **kwargs):
captured["kwargs"] = kwargs
return SimpleNamespace(data=[SimpleNamespace(b64_json=_b64_png(), url=None)])
class _FakeClient:
def __init__(self, api_key=None, base_url=None):
captured["api_key"] = api_key
captured["base_url"] = base_url
self.images = _FakeImages()
fake_openai = MagicMock()
fake_openai.OpenAI = _FakeClient
with patch.dict("sys.modules", {"openai": fake_openai}):
result = deepinfra_plugin.DeepInfraImageGenProvider().generate(
prompt="a cat", aspect_ratio="square",
)
assert result["success"] is True
assert "deepinfra" in captured["base_url"]
assert captured["api_key"] == "test-key"
assert captured["kwargs"]["model"] == "vendor/test-img"
def test_capabilities_advertise_text_to_image_only():
assert deepinfra_plugin.DeepInfraImageGenProvider().capabilities() == {
"modalities": ["text"],
"max_reference_images": 0,
}
@@ -0,0 +1,132 @@
#!/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
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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,705 @@
#!/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_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_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_creativity_default(self):
from plugins.image_gen.krea import _resolve_creativity
assert _resolve_creativity(None) == "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", upscale=False)
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", upscale=False)
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",
upscale=False,
)
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},
],
upscale=False,
)
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_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_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
# ---------------------------------------------------------------------------
# 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", upscale=False)
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_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()
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", upscale=False)
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")
# ---------------------------------------------------------------------------
# Upscale pass (Krea Enhance)
# ---------------------------------------------------------------------------
class TestUpscalePass:
def _run_generate(self, *, upscale, enhance_job, model=None):
"""Drive generate() with sequenced post/get mocks.
Sequence: generation submit POST → generation poll GET; then (when
upscale fires) enhance submit POST → enhance poll GET.
"""
from plugins.image_gen.krea import KreaImageGenProvider
gen_submit = _submit_response()
gen_poll = _poll_response(_completed_job("https://krea.cdn/native.png"))
enh_submit = _submit_response("00000000-0000-0000-0000-00000000e0e0")
enh_poll = _poll_response(enhance_job) if enhance_job else None
posts = [gen_submit, enh_submit]
gets = [gen_poll] + ([enh_poll] if enh_poll else [])
kwargs = {"prompt": "a lamp", "upscale": upscale}
if model is not None:
kwargs["model"] = model
with patch("plugins.image_gen.krea.requests.post", side_effect=posts) as mock_post, \
patch("plugins.image_gen.krea.requests.get", side_effect=gets) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
side_effect=lambda url, prefix: Path(f"/tmp/{url.rsplit('/', 1)[-1]}"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(**kwargs)
return result, mock_post, mock_get
def test_upscale_routes_through_enhance_endpoint(self):
enhance_job = {
"job_id": "00000000-0000-0000-0000-00000000e0e0",
"status": "completed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": ["https://krea.cdn/enhanced.png"]},
}
result, mock_post, _ = self._run_generate(upscale=True, enhance_job=enhance_job)
assert result["success"] is True
assert result["upscaled"] is True
assert result["upscale_factor"] == 2
assert result["image"].endswith("enhanced.png")
# Second POST hit the Enhance endpoint with the native image + factor.
assert mock_post.call_count == 2
enh_url = mock_post.call_args_list[1][0][0]
assert enh_url.endswith("/generate/enhance/krea/enhance")
enh_payload = mock_post.call_args_list[1].kwargs["json"]
assert enh_payload["image_url"] == "https://krea.cdn/native.png"
assert enh_payload["image_scaling_factor"] == 2
assert enh_payload["prompt"] == "a lamp"
def test_upscale_failure_falls_back_to_native(self):
failed_job = {
"job_id": "00000000-0000-0000-0000-00000000e0e0",
"status": "failed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:01:00Z",
"result": None,
}
result, mock_post, _ = self._run_generate(upscale=True, enhance_job=failed_job)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 2 # enhance attempted, fell back
def test_medium_skips_upscale_by_default(self):
"""Upscaling is opt-in only (Aug 2026 policy) — even for
krea-2-medium's 1.5K native output, no automatic Enhance pass."""
result, mock_post, _ = self._run_generate(upscale=None, enhance_job=None)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1 # only the generation submit
def test_large_skips_upscale_by_default(self):
"""krea-2-large: no automatic Enhance pass either."""
result, mock_post, _ = self._run_generate(
upscale=None, enhance_job=None, model="krea-2-large",
)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1 # only the generation submit
def test_explicit_false_disables_default(self):
"""Explicit upscale=False matches the off default."""
result, mock_post, _ = self._run_generate(upscale=False, enhance_job=None)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1
# ---------------------------------------------------------------------------
# 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,316 @@
"""Tests for the bundled Meta Model API image_gen plugin (muse-image)."""
from __future__ import annotations
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
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.
meta_plugin = importlib.import_module("plugins.image_gen.meta-ai")
# 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))
# Clear every auth + override env var so tests start from a clean slate.
for env in (
"MODEL_API_KEY",
"META_API_KEY",
"META_MODEL_API_KEY",
"META_BASE_URL",
"META_IMAGE_MODEL",
):
monkeypatch.delenv(env, raising=False)
yield tmp_path
@pytest.fixture
def provider(monkeypatch):
monkeypatch.setenv("META_MODEL_API_KEY", "test-key")
return meta_plugin.MetaImageGenProvider()
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 == "meta-ai"
def test_display_name(self, provider):
assert provider.display_name == "Meta Model API"
def test_default_model(self, provider):
assert provider.default_model() == "muse-image-1.0"
def test_list_models(self, provider):
ids = [m["id"] for m in provider.list_models()]
assert ids == ["muse-image-1.0"]
def test_catalog_entries_have_display_speed_strengths_price(self, provider):
for entry in provider.list_models():
assert entry["display"]
assert entry["speed"]
assert entry["strengths"]
assert entry["price"]
def test_text_only_capabilities(self, provider):
caps = provider.capabilities()
assert caps["modalities"] == ["text"]
assert caps["max_reference_images"] == 0
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_no_api_key_unavailable(self):
assert meta_plugin.MetaImageGenProvider().is_available() is False
@pytest.mark.parametrize(
"env", ["MODEL_API_KEY", "META_API_KEY", "META_MODEL_API_KEY"]
)
def test_each_auth_alias_makes_available(self, monkeypatch, env):
monkeypatch.setenv(env, "test")
assert meta_plugin.MetaImageGenProvider().is_available() is True
# ── Auth / base-url resolution ────────────────────────────────────────────────
class TestResolution:
def test_api_key_priority_order(self, monkeypatch):
# MODEL_API_KEY wins over the aliases.
monkeypatch.setenv("META_MODEL_API_KEY", "third")
monkeypatch.setenv("META_API_KEY", "second")
monkeypatch.setenv("MODEL_API_KEY", "first")
assert meta_plugin._resolve_api_key() == "first"
def test_default_base_url(self):
assert meta_plugin._resolve_base_url() == "https://api.meta.ai/v1"
def test_base_url_override(self, monkeypatch):
monkeypatch.setenv("META_BASE_URL", "https://proxy.internal/v1")
assert meta_plugin._resolve_base_url() == "https://proxy.internal/v1"
# ── Model resolution ──────────────────────────────────────────────────────────
class TestModelResolution:
def test_default(self):
model_id, _meta = meta_plugin._resolve_model()
assert model_id == "muse-image-1.0"
def test_env_var_override_ignores_unknown(self, monkeypatch):
monkeypatch.setenv("META_IMAGE_MODEL", "not-a-real-model")
model_id, _meta = meta_plugin._resolve_model()
# Unknown id is ignored; falls through to the default.
assert model_id == "muse-image-1.0"
def test_caller_model_kwarg_wins(self, monkeypatch):
# The dispatcher forwards top-level image_gen.model as the `model`
# kwarg; it must beat the env override (#55893 bug class).
monkeypatch.setitem(
meta_plugin._MODELS,
"muse-image-test",
dict(meta_plugin._MODELS["muse-image-1.0"]),
)
monkeypatch.setenv("META_IMAGE_MODEL", "muse-image-1.0")
model_id, _meta = meta_plugin._resolve_model("muse-image-test")
assert model_id == "muse-image-test"
def test_caller_model_unknown_falls_through(self):
model_id, _meta = meta_plugin._resolve_model("not-a-real-model")
assert model_id == "muse-image-1.0"
# ── Generate ──────────────────────────────────────────────────────────────────
class TestGenerate:
def test_model_kwarg_reaches_payload(self, provider, monkeypatch):
monkeypatch.setitem(
meta_plugin._MODELS,
"muse-image-test",
dict(meta_plugin._MODELS["muse-image-1.0"]),
)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat", model="muse-image-test")
assert result["success"] is True
assert (
fake_client.images.generate.call_args.kwargs["model"] == "muse-image-test"
)
def test_badge_is_standard_paid(self, provider):
assert provider.get_setup_schema()["badge"] == "paid"
def test_empty_prompt_rejected(self, provider):
result = provider.generate("", aspect_ratio="square")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
assert result["provider"] == "meta-ai"
def test_missing_api_key(self):
result = meta_plugin.MetaImageGenProvider().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"] == "muse-image-1.0"
assert result["aspect_ratio"] == "landscape"
assert result["provider"] == "meta-ai"
assert result["modality"] == "text"
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
assert call_kwargs["model"] == "muse-image-1.0"
assert call_kwargs["size"] == "1536x1024"
assert call_kwargs["n"] == 1
def test_client_uses_meta_base_url(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
with patch.dict("sys.modules", {"openai": fake_openai}):
provider.generate("a cat")
assert (
fake_openai.OpenAI.call_args.kwargs["base_url"] == "https://api.meta.ai/v1"
)
def test_base_url_override_reaches_client(self, provider, monkeypatch):
monkeypatch.setenv("META_BASE_URL", "https://proxy.internal/v1")
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
with patch.dict("sys.modules", {"openai": fake_openai}):
provider.generate("a cat")
assert (
fake_openai.OpenAI.call_args.kwargs["base_url"]
== "https://proxy.internal/v1"
)
@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_url_response_is_cached_locally(self, provider):
"""A URL response is materialized locally (symmetric to the openai/xai
providers) 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.webp",
)
with (
_patched_openai(fake_client),
patch.object(
meta_plugin,
"save_url_image",
return_value=Path("/tmp/meta_20260524_000000_deadbeef.webp"),
) 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_empty_response_errors(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=None, url=None)
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_api_error_surfaced(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"]
@@ -0,0 +1,467 @@
"""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
import json
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_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": _b64_png(), "source": "final"})
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"
assert result.get("image_source") == "final"
assert result.get("pixel_size") == "1x1"
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": _b64_png(), "source": "final"}
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"
# Regression for #19505: the Codex backend 400s on every tool_choice
# shape we have for the hosted ``image_generation`` tool, so the
# provider must omit tool_choice entirely and rely on instructions.
assert "tool_choice" not in captured
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"
# Progressive previews disabled: partial frames were being saved as
# finals and presented as smeared/unfinished images.
assert tool["partial_images"] == 0
def test_capabilities_advertise_image_inputs(self, provider):
caps = provider.capabilities()
assert caps["modalities"] == ["text", "image"]
assert caps["max_reference_images"] == 16
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_partial_image_event_used_when_done_missing(self):
"""Extractor may surface partial b64 when no final exists (fallback only)."""
payload = {
"type": "response.image_generation_call.partial_image",
"partial_image_b64": _b64_png(),
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
result, partial = codex_plugin._extract_image_candidates(payload)
assert result is None
assert partial == _b64_png()
def test_final_result_wins_over_coexisting_partial_in_same_payload(self):
"""Blind spot that shipped the smear bug: both fields in one payload.
partial_image_b64 must never overwrite image_generation_call.result
when they coexist in the same event tree.
"""
final = _b64_png()
# Distinct non-empty stand-in so equality proves which field won.
partial = "cGFydGlhbC1vbmx5LW5vdC1hLXJlYWwtZmluYWw="
payload = {
"type": "response.output_item.done",
"item": {
"type": "image_generation_call",
"status": "completed",
"result": final,
"partial_image_b64": partial,
},
}
assert codex_plugin._extract_image_b64(payload) == final
result, got_partial = codex_plugin._extract_image_candidates(payload)
assert result == final
assert got_partial == partial
def test_nested_final_wins_over_sibling_partial(self):
payload = {
"type": "response.completed",
"response": {
"output": [{
"type": "image_generation_call",
"status": "completed",
"result": _b64_png(),
}],
},
"partial_image_b64": "cGFydGlhbC1zaWJsaW5n",
}
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_partial_only_stream_fails_closed_after_retry(self, provider, monkeypatch):
"""Partial-only streams must not return success:true with a smear frame."""
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _partial_only(*args, **kwargs):
calls["n"] += 1
return {"b64": _b64_png(), "source": "partial"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _partial_only)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "incomplete_image"
assert "partial" in result["error"].lower()
# One initial attempt + one content-agnostic retry.
assert calls["n"] == codex_plugin._NONFINAL_RETRIES + 1
def test_empty_stream_retries_then_fails(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _empty(*args, **kwargs):
calls["n"] += 1
return None
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _empty)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
assert calls["n"] == codex_plugin._NONFINAL_RETRIES + 1
def test_partial_then_final_on_retry_succeeds(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _then_final(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return {"b64": _b64_png(), "source": "partial"}
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _then_final)
result = provider.generate("a cat")
assert result["success"] is True
assert result.get("image_source") == "final"
assert calls["n"] == 2
def test_empty_then_final_on_retry_succeeds(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _then_final(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return None
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _then_final)
result = provider.generate("a cat")
assert result["success"] is True
assert result.get("image_source") == "final"
assert calls["n"] == 2
def test_empty_response_returns_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_NONFINAL_RETRIES", 0)
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"]
def test_tool_choice_400_surfaces_verbatim_not_as_capability_error(
self, provider, monkeypatch
):
"""The tool_choice 400 must NOT be reported as an account limitation.
Regression for #19505 / #49008 / #31335: a previous version classified
this exact request-shape rejection as "Image generation is not enabled
for the current Codex account", telling every affected user to abandon
Codex over a bug in our own payload. The wire error must reach the user
unedited so it stays diagnosable.
Drives the REAL httpx boundary (not a mocked ``_collect_image_b64``) so
the classification path is actually exercised — mocking the collector
would skip the code under test entirely.
"""
import httpx
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
body = json.dumps({
"error": {
"message": "Tool choice 'image_generation' not found in 'tools' parameter.",
"type": "invalid_request_error",
"param": "tool_choice",
}
})
def _handler(request):
return httpx.Response(400, text=body, request=request)
real_client = httpx.Client
monkeypatch.setattr(
httpx,
"Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler),
headers=kwargs.get("headers"),
timeout=kwargs.get("timeout"),
),
)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "HTTP 400" in result["error"]
assert "tools' parameter" in result["error"]
# The account-entitlement misdiagnosis must not come back.
assert "not enabled for the current Codex account" not in result["error"]
assert result["error_type"] != "capability_unsupported"
class TestRequestShape:
def test_payload_omits_tool_choice(self):
"""Codex rejects every tool_choice shape for hosted image_generation."""
payload = codex_plugin._build_responses_payload(
prompt="a red circle",
size="1024x1024",
quality="low",
)
assert "tool_choice" not in payload
# The hosted tool itself is still requested, and instructions do the steering.
assert payload["tools"][0]["type"] == "image_generation"
assert payload["instructions"]
def test_http_error_body_is_truncated_but_preserved(self, monkeypatch):
"""A large error body is capped at 500 chars and still surfaced."""
import httpx
body = json.dumps({
"metadata": "x" * 600,
"error": {
"message": "Tool choice 'image_generation' not found in 'tools' parameter."
},
})
def _handler(request):
return httpx.Response(400, text=body, request=request)
real_client = httpx.Client
monkeypatch.setattr(
httpx,
"Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler),
headers=kwargs.get("headers"),
timeout=kwargs.get("timeout"),
),
)
with pytest.raises(RuntimeError, match="HTTP 400") as excinfo:
codex_plugin._collect_image_b64(
"codex-token",
prompt="a cat",
size="1024x1024",
quality="low",
)
message = str(excinfo.value)
# Body is capped, but the actionable wire message still reaches the user.
assert "tools' parameter" in message
assert len(message) < len(body)
# ── 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,242 @@
"""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_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_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"
# ── 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_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"
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_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()
@@ -0,0 +1,787 @@
#!/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_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_model_ignores_runtime_overrides(self, monkeypatch):
"""Catalog defaults must not inherit another provider's saved model."""
from plugins.image_gen.openrouter import DEFAULT_MODEL
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "custom/provider-image-model")
stale = {"model": "gpt-image-2-medium"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=stale):
provider = _openrouter()
assert provider.default_model() == DEFAULT_MODEL
assert provider._resolve_model() == "custom/provider-image-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_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"
]
# ---------------------------------------------------------------------------
# Live model catalog
# ---------------------------------------------------------------------------
def _mock_models_response(entries):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {"data": entries}
return resp
class TestLiveCatalog:
def test_live_catalog_lists_all_image_output_models(self):
"""Every image-output model on the endpoint is selectable — including
ones released after this code shipped."""
entries = [
{
"id": "openai/gpt-5.4-image-2",
"name": "GPT-5.4 Image 2",
"architecture": {"output_modalities": ["image"], "input_modalities": ["text", "image"]},
},
{
"id": "some-lab/brand-new-image-model",
"name": "Brand New",
"architecture": {"output_modalities": ["image", "text"], "input_modalities": ["text"]},
},
{
"id": "openai/gpt-5.4", # text-only: excluded
"architecture": {"output_modalities": ["text"], "input_modalities": ["text"]},
},
{
"id": "openrouter/auto", # router pseudo-model: excluded
"architecture": {"output_modalities": ["image", "text"], "input_modalities": ["text"]},
},
]
provider = _openrouter()
with patch(_RUNTIME, return_value=_runtime_ok()), patch(
"requests.get", return_value=_mock_models_response(entries)
):
models = provider.list_models()
ids = [m["id"] for m in models]
assert "openai/gpt-5.4-image-2" in ids
assert "some-lab/brand-new-image-model" in ids
assert "openai/gpt-5.4" not in ids
assert "openrouter/auto" not in ids
# Default chain models sort first.
assert ids[0] == "openai/gpt-5.4-image-2"
def test_live_failure_falls_back_to_static_chain(self):
provider = _openrouter()
with patch(_RUNTIME, side_effect=RuntimeError("no creds")):
models = provider.list_models()
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
assert [m["id"] for m in models] == [DEFAULT_MODEL, _FALLBACK_MODEL]
def test_live_catalog_is_cached(self):
provider = _openrouter()
entries = [
{
"id": "openai/gpt-5.4-image-2",
"architecture": {"output_modalities": ["image"], "input_modalities": ["text"]},
}
]
with patch(_RUNTIME, return_value=_runtime_ok()), patch(
"requests.get", return_value=_mock_models_response(entries)
) as mock_get:
provider.list_models()
provider.list_models()
assert mock_get.call_count == 1
def test_picker_merges_image_api_and_chat_catalogs(self):
"""OpenRouter picker = union of /images/models and image-output
/models entries, deduped, defaults first."""
from plugins.image_gen.openrouter import _build_providers
orp = {p.name: p for p in _build_providers()}["openrouter"]
def fake_get(url, **kw):
resp = MagicMock()
resp.raise_for_status = MagicMock()
if url.endswith("/images/models"):
resp.json.return_value = {"data": [
{"id": "bytedance-seed/seedream-4.5", "name": "Seedream 4.5",
"architecture": {"input_modalities": ["text", "image"],
"output_modalities": ["image"]}},
{"id": "openai/gpt-5.4-image-2", "name": "GPT-5.4 Image 2",
"architecture": {"input_modalities": ["text", "image"],
"output_modalities": ["image"]}},
]}
else:
resp.json.return_value = {"data": [
{"id": "openai/gpt-5.4-image-2",
"architecture": {"output_modalities": ["image"],
"input_modalities": ["text", "image"]}},
{"id": "google/gemini-3-pro-image",
"architecture": {"output_modalities": ["image"],
"input_modalities": ["text", "image"]}},
]}
return resp
with patch(_RUNTIME, return_value=_runtime_ok()), patch("requests.get", side_effect=fake_get):
ids = [m["id"] for m in orp.list_models()]
assert ids[0] == "openai/gpt-5.4-image-2" # default first
assert "bytedance-seed/seedream-4.5" in ids # Image-API-only model present
assert "google/gemini-3-pro-image" in ids # chat-catalog model present
assert len(ids) == len(set(ids)) # deduped
def test_nous_portal_picker_excludes_image_api_catalog(self):
"""Nous Portal has no /images route; its picker must not offer
Image-API-only models it cannot serve."""
from plugins.image_gen.openrouter import _build_providers
nous = {p.name: p for p in _build_providers()}["nous"]
with patch(_RUNTIME, side_effect=RuntimeError("no creds")):
ids = [m["id"] for m in nous.list_models()]
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
assert ids == [DEFAULT_MODEL, _FALLBACK_MODEL]
# ---------------------------------------------------------------------------
# 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_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_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_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
# ---------------------------------------------------------------------------
# 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_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"
# ---------------------------------------------------------------------------
# Registration + pet integration
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Dedicated Image API surface (POST /images/generations)
# ---------------------------------------------------------------------------
def _openrouter_image_api():
"""The provider as `_build_providers` really configures it (surface on)."""
from plugins.image_gen.openrouter import _build_providers
return {p.name: p for p in _build_providers()}["openrouter"]
def _mock_image_api_response(entries=None, usage=None):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
body = {"created": 0, "data": entries if entries is not None else [
{"b64_json": "dGVzdA==", "media_type": "image/png"}
]}
if usage is not None:
body["usage"] = usage
resp.json.return_value = body
return resp
class TestImageApiSurface:
@pytest.fixture(autouse=True)
def _isolate(self, monkeypatch):
"""No config bleed, no catalog cache bleed between tests."""
import plugins.image_gen.openrouter as mod
mod._CATALOG_CACHE.clear()
monkeypatch.setattr(mod, "_load_image_gen_config", lambda: {})
for knob in ("QUALITY", "BACKGROUND", "RESOLUTION", "SEED", "N",
"ASPECT_RATIO", "TIMEOUT", "SURFACE"):
monkeypatch.delenv(f"OPENROUTER_IMAGE_API_{knob}", raising=False)
monkeypatch.delenv("OPENROUTER_IMAGE_MODEL", raising=False)
yield
mod._CATALOG_CACHE.clear()
# -- routing ---------------------------------------------------------
def test_curated_model_routes_without_any_probe(self):
"""The static table answers the common case offline."""
from plugins.image_gen.openrouter import _select_surface
with patch("requests.get", side_effect=AssertionError("must not probe")):
assert _select_surface("openai/gpt-image-2", "https://x/api/v1", "k", "openrouter") == "images"
def test_chat_defaults_stay_on_chat_even_though_the_catalog_lists_them(self):
"""The regression this guards: /images/models is a superset that
includes DEFAULT_MODEL and _FALLBACK_MODEL. Routing on catalog
membership would silently move every existing default call."""
from plugins.image_gen.openrouter import (
DEFAULT_MODEL,
_FALLBACK_MODEL,
_select_surface,
)
catalog = MagicMock()
catalog.raise_for_status = MagicMock()
catalog.json.return_value = {
"data": [{"id": DEFAULT_MODEL}, {"id": _FALLBACK_MODEL}]
}
with patch("requests.get", return_value=catalog):
assert _select_surface(DEFAULT_MODEL, "https://x/api/v1", "k", "openrouter") == "chat"
assert _select_surface(_FALLBACK_MODEL, "https://x/api/v1", "k", "openrouter") == "chat"
def test_unknown_catalog_model_routes_to_image_api(self):
"""An id past the curated snapshot but in the live catalog is served
by the dedicated API — a model picked from the live picker must not
fall onto chat-completions and 404."""
import plugins.image_gen.openrouter as orp
from plugins.image_gen.openrouter import _select_surface
orp._CATALOG_CACHE.clear()
catalog = MagicMock()
catalog.raise_for_status = MagicMock()
catalog.json.return_value = {"data": [{"id": "brandnew/model-9"}]}
with patch("requests.get", return_value=catalog) as mock_get:
assert _select_surface("brandnew/model-9", "https://x/api/v1", "k", "openrouter") == "images"
assert _select_surface("brandnew/model-9", "https://x/api/v1", "k", "openrouter") == "images"
# Catalog probe is cached — one fetch serves repeat calls.
assert mock_get.call_count == 1
def test_unknown_model_not_in_catalog_stays_on_chat(self):
import plugins.image_gen.openrouter as orp
from plugins.image_gen.openrouter import _select_surface
orp._CATALOG_CACHE.clear()
catalog = MagicMock()
catalog.raise_for_status = MagicMock()
catalog.json.return_value = {"data": [{"id": "something/else"}]}
with patch("requests.get", return_value=catalog):
assert _select_surface("not-served/model", "https://x/api/v1", "k", "openrouter") == "chat"
def test_failed_probe_costs_nothing(self):
import plugins.image_gen.openrouter as orp
from plugins.image_gen.openrouter import _select_surface
orp._CATALOG_CACHE.clear()
with patch("requests.get", side_effect=OSError("network down")):
assert _select_surface("unknown/model", "https://x/api/v1", "k", "openrouter") == "chat"
def test_surface_can_be_forced_both_ways(self, monkeypatch):
from plugins.image_gen.openrouter import DEFAULT_MODEL, _select_surface
monkeypatch.setenv("OPENROUTER_IMAGE_API_SURFACE", "images")
with patch("requests.get", side_effect=AssertionError("must not probe")):
assert _select_surface(DEFAULT_MODEL, "https://x/api/v1", "k", "openrouter") == "images"
monkeypatch.setenv("OPENROUTER_IMAGE_API_SURFACE", "chat")
with patch("requests.get", side_effect=AssertionError("must not probe")):
assert _select_surface("openai/gpt-image-2", "https://x/api/v1", "k", "openrouter") == "chat"
def test_image_api_model_posts_to_images_generations(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response()) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="a red square", aspect_ratio="square", model="openai/gpt-image-2"
)
assert result["success"] is True
assert mock_post.call_args[0][0] == "https://openrouter.ai/api/v1/images/generations"
payload = mock_post.call_args.kwargs["json"]
assert payload["model"] == "openai/gpt-image-2"
assert payload["prompt"] == "a red square"
assert payload["aspect_ratio"] == "1:1"
assert "messages" not in payload and "modalities" not in payload
assert result["endpoint"] == "images/generations"
def test_chat_model_still_uses_chat_completions(self):
"""The new surface must not capture the existing default chain."""
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_image_api().generate(prompt="a pet")
assert result["success"] is True
assert mock_post.call_args[0][0].endswith("/chat/completions")
def test_nous_never_uses_the_image_api(self):
"""Nous Portal proxies chat-completions and has no /images route."""
from plugins.image_gen.openrouter import _build_providers
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")):
nous = {p.name: p for p in _build_providers()}["nous"]
result = nous.generate(prompt="a pet", model="openai/gpt-image-2")
assert result["success"] is True
assert mock_post.call_args[0][0] == "https://inference.nousresearch.com/v1/chat/completions"
# -- per-model parameter filtering ------------------------------------
def test_aspect_ratio_is_mapped_per_model(self):
from plugins.image_gen.openrouter import _build_image_api_payload
gemini, _ = _build_image_api_payload(
model_id="google/gemini-3.1-flash-lite-image", prompt="p",
semantic_aspect="landscape", references=[], config_key="openrouter", kwargs={},
)
mini, _ = _build_image_api_payload(
model_id="openai/gpt-image-1-mini", prompt="p",
semantic_aspect="landscape", references=[], config_key="openrouter", kwargs={},
)
# gpt-image-1-mini has no 16:9 at all, so landscape degrades to 3:2.
assert gemini["aspect_ratio"] == "16:9"
assert mini["aspect_ratio"] == "3:2"
def test_unsupported_parameter_is_dropped_and_explained(self):
"""The endpoint silently ignores unknown fields, so we must filter."""
from plugins.image_gen.openrouter import _build_image_api_payload
payload, notes = _build_image_api_payload(
model_id="openai/gpt-image-2", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={"background": "transparent"},
)
assert "background" not in payload
assert any("background" in n for n in notes)
payload, notes = _build_image_api_payload(
model_id="openai/gpt-image-1-mini", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={"background": "transparent"},
)
assert payload["background"] == "transparent"
def test_n_is_clamped_to_the_model_cap(self):
from plugins.image_gen.openrouter import _build_image_api_payload
payload, notes = _build_image_api_payload(
model_id="qwen/qwen-image-3-pro", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={"n": 20},
)
assert payload["n"] == 6
assert any("cap of 6" in n for n in notes)
def test_unknown_model_omits_the_aspect_ratio(self):
"""An out-of-enum ratio is a hard 400, so never guess one."""
from plugins.image_gen.openrouter import _build_image_api_payload
payload, notes = _build_image_api_payload(
model_id="brandnew/model-9", prompt="p", semantic_aspect="landscape",
references=[], config_key="openrouter", kwargs={},
)
assert "aspect_ratio" not in payload
assert any("catalog" in n for n in notes)
def test_env_knob_applies(self, monkeypatch):
from plugins.image_gen.openrouter import _build_image_api_payload
monkeypatch.setenv("OPENROUTER_IMAGE_API_QUALITY", "high")
payload, _ = _build_image_api_payload(
model_id="openai/gpt-image-2", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={},
)
assert payload["quality"] == "high"
# -- references --------------------------------------------------------
def test_references_use_the_per_model_cap(self, tmp_path):
"""Image API models take far more references than chat's 3."""
refs = []
for i in range(5):
p = tmp_path / f"r{i}.png"
p.write_bytes(b"\x89PNG\r\n")
refs.append(str(p))
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response()) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="edit", model="openai/gpt-image-2", reference_image_urls=refs
)
payload = mock_post.call_args.kwargs["json"]
assert len(payload["input_references"]) == 5 # chat would have clamped to 3
assert payload["input_references"][0]["image_url"]["url"].startswith("data:image/png;base64,")
assert result["modality"] == "image"
def test_unreadable_sole_reference_fails_instead_of_degrading(self):
"""Degrading an edit to text-to-image bills an unrelated picture."""
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post") as mock_post:
result = _openrouter_image_api().generate(
prompt="edit this", model="openai/gpt-image-2",
image_url="/nonexistent/definitely-missing.png",
)
assert result["success"] is False
assert result["error_type"] == "io_error"
mock_post.assert_not_called()
# -- response handling -------------------------------------------------
def test_cost_and_extras_are_surfaced(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response(
usage={"cost": 0.0336, "total_tokens": 1128})), \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="p", aspect_ratio="portrait", model="krea/krea-2-medium"
)
assert result["cost_usd"] == 0.0336
assert result["total_tokens"] == 1128
assert result["exact_aspect_ratio"] == "9:16"
assert result["image"] == "/tmp/i.png"
def test_multiple_images_land_in_additional_images(self):
entries = [
{"b64_json": "AA==", "media_type": "image/png"},
{"b64_json": "BB==", "media_type": "image/png"},
]
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response(entries)), \
patch("plugins.image_gen.openrouter.save_b64_image",
side_effect=[Path("/tmp/a.png"), Path("/tmp/b.png")]):
result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2")
assert result["image"] == "/tmp/a.png"
assert result["additional_images"] == ["/tmp/b.png"]
def test_empty_data_is_typed(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response([])):
result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_zod_validation_error_is_flattened(self):
from plugins.image_gen.openrouter import _extract_image_api_error
resp = MagicMock()
resp.json.return_value = {
"success": False,
"error": {
"name": "ZodError",
"message": '[{"path":["aspect_ratio"],"message":"Invalid option"}]',
},
}
assert _extract_image_api_error(resp, "fb").startswith("aspect_ratio: Invalid option")
def test_auth_error_is_not_retried_as_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):
result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2")
assert result["success"] is False
assert result["error_type"] == "auth_error"
assert "_retryable" not in result
def test_catalog_models_are_offered_only_by_openrouter(self):
from plugins.image_gen.openrouter import _IMAGE_API_MODELS, _build_providers
by_name = {p.name: p for p in _build_providers()}
openrouter_ids = {m["id"] for m in by_name["openrouter"].list_models()}
nous_ids = {m["id"] for m in by_name["nous"].list_models()}
assert "openai/gpt-image-2" in openrouter_ids
assert set(_IMAGE_API_MODELS) <= openrouter_ids
assert not (set(_IMAGE_API_MODELS) & nous_ids)
def test_default_model_is_unchanged_by_the_new_surface(self):
from plugins.image_gen.openrouter import DEFAULT_MODEL
assert _openrouter_image_api().default_model() == DEFAULT_MODEL
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,539 @@
#!/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
@pytest.fixture(autouse=True)
def _no_live_catalog(monkeypatch):
"""Keep unit tests hermetic: never hit xAI's live model-list endpoint.
The fake XAI_API_KEY above would otherwise let ``_fetch_live_models``
fire a real GET. Individual tests that exercise the live-merge path
re-patch ``_fetch_live_models`` themselves.
"""
import plugins.image_gen.xai as xai_mod
def _offline():
raise RuntimeError("offline (test)")
monkeypatch.setattr(xai_mod, "_fetch_live_models", _offline)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
yield
xai_mod._LIVE_CACHE = None
# ---------------------------------------------------------------------------
# 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_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_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"
def test_caller_model_overrides_env(self, monkeypatch):
"""caller_model (from image_gen.model config key) must take priority
over XAI_IMAGE_MODEL env — mirrors the fix applied to the openrouter
provider in #55672."""
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
from plugins.image_gen.xai import _resolve_model
model_id, _ = _resolve_model("grok-imagine-image-quality")
assert model_id == "grok-imagine-image-quality"
def test_unknown_caller_model_falls_back_to_env(self, monkeypatch):
"""An unrecognised caller_model must not crash — fall through to env."""
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
from plugins.image_gen.xai import _resolve_model
model_id, _ = _resolve_model("not-a-real-model")
assert model_id == "grok-imagine-image"
def test_model_kwarg_forwarded_to_generate(self):
"""generate(model=...) must use the supplied model, not the default."""
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:
with patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/out.png"):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test", model="grok-imagine-image-quality")
assert result["success"] is True
assert result["model"] == "grok-imagine-image-quality"
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json", {})
assert payload.get("model") == "grok-imagine-image-quality"
# ---------------------------------------------------------------------------
# Live catalog merge tests
# ---------------------------------------------------------------------------
class TestLiveCatalog:
def test_static_catalog_includes_image_2_0(self):
"""Curated table carries the 2.0 model even offline."""
from plugins.image_gen.xai import XAIImageGenProvider
ids = [m["id"] for m in XAIImageGenProvider().list_models()]
assert "grok-imagine-image-2.0" in ids
def test_unknown_live_model_appears_in_catalog(self, monkeypatch):
"""A model xAI ships tomorrow shows up without a code change."""
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-3.0": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
catalog = xai_mod._catalog()
assert "grok-imagine-image-3.0" in catalog
# Curated metadata survives the merge for known models.
assert catalog["grok-imagine-image"]["display"] == "Grok Imagine Image"
# And the new model is selectable end to end.
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image-3.0")
model_id, _ = xai_mod._resolve_model()
assert model_id == "grok-imagine-image-3.0"
def test_live_failure_falls_back_to_static(self, monkeypatch):
import plugins.image_gen.xai as xai_mod
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
catalog = xai_mod._catalog() # autouse fixture makes fetch raise
assert set(catalog) == set(xai_mod._MODELS)
def test_edit_model_honors_image_capable_selection(self, monkeypatch):
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image-2.0": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-quality": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image-2.0")
assert xai_mod._resolve_edit_model() == "grok-imagine-image-2.0"
def test_edit_model_defaults_to_quality(self, monkeypatch):
import plugins.image_gen.xai as xai_mod
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.delenv("XAI_IMAGE_MODEL", raising=False)
assert xai_mod._resolve_edit_model() == "grok-imagine-image-quality"
def test_edit_model_honors_caller_kwarg(self, monkeypatch):
"""The dispatched model kwarg reaches the edit path too."""
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image-2.0": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-quality": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.delenv("XAI_IMAGE_MODEL", raising=False)
assert xai_mod._resolve_edit_model("grok-imagine-image-2.0") == "grok-imagine-image-2.0"
# Text-only caller model must not hijack the edit path.
live["grok-imagine-image-2.0"]["input_modalities"] = ["text"]
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
assert xai_mod._resolve_edit_model("grok-imagine-image-2.0") == "grok-imagine-image-quality"
# ---------------------------------------------------------------------------
# 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_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_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_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_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))
View File
@@ -0,0 +1,18 @@
"""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
@@ -0,0 +1,67 @@
"""Tests for config-schema loading from memory provider plugin dirs."""
import plugins.memory.config_schema as config_schema
from plugins.memory.config_schema import get_provider_config_schema
def test_unknown_provider_is_none():
assert get_provider_config_schema("builtin") is None
def test_plugin_without_schema_is_none():
# mem0 is a real plugin dir that declares no config_schema.py.
assert get_provider_config_schema("mem0") is None
def test_schemas_are_cached_per_provider():
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")
def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path):
# User-installed plugins are per-profile; two profiles' plugins sharing a
# name must not answer for each other.
import plugins.memory as memory
schemas = {}
for label in ("A", "B"):
plugin_dir = tmp_path / label / "custom"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.py").write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n',
encoding="utf-8",
)
schemas[label] = plugin_dir
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"])
assert get_provider_config_schema("custom").label == "A"
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"])
assert get_provider_config_schema("custom").label == "B"
def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
# A load failure must retry on the next request, not pin an empty panel.
broken_dir = tmp_path / "broken"
broken_dir.mkdir()
schema_file = broken_dir / "config_schema.py"
schema_file.write_text("this is not python(", encoding="utf-8")
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
import plugins.memory as memory
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
assert get_provider_config_schema("broken") is None
assert not config_schema._SCHEMA_CACHE
schema_file.write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
'CONFIG_SCHEMA = ProviderConfigSchema(name="broken", label="Broken")\n',
encoding="utf-8",
)
recovered = get_provider_config_schema("broken")
assert recovered is not None
assert recovered.label == "Broken"
@@ -0,0 +1,221 @@
"""Discovery parity for out-of-tree memory providers.
Upstream policy closed ``plugins/memory/`` to new providers, so every new
memory backend now lives outside this tree. These tests cover the two sources
that reach it — project-local directories and pip entry points — and the
integration points a directory install gets for free but a pip install
historically did not: the dashboard config panel, the provider's CLI
subcommands, and the ``memory.provider`` dropdown.
"""
from __future__ import annotations
import importlib.metadata
import sys
import textwrap
from pathlib import Path
import pytest
import plugins.memory as memory_plugins
PROVIDER_SOURCE = """\
from agent.memory_provider import MemoryProvider
class Provider(MemoryProvider):
@property
def name(self):
return "{name}"
def is_available(self):
return True
def initialize(self, *a, **kw):
pass
def get_tool_schemas(self):
return []
def register(ctx):
ctx.register_memory_provider(Provider())
"""
class FakeEntryPoint:
"""Mirrors the importlib.metadata EntryPoint surface discovery uses."""
group = "hermes_agent.memory_providers"
def __init__(self, name, value):
self.name = name
self.value = value
def load(self):
import importlib
module_name, _, attr = self.value.partition(":")
module = importlib.import_module(module_name)
return getattr(module, attr) if attr else module
class FakeEntryPoints(list):
def select(self, *, group):
return [ep for ep in self if ep.group == group]
@pytest.fixture
def entry_points(monkeypatch):
"""Install a replaceable entry-point set for the memory group."""
registry = FakeEntryPoints()
monkeypatch.setattr(importlib.metadata, "entry_points", lambda: registry)
return registry
def _write_provider_dir(root: Path, name: str) -> Path:
provider = root / name
provider.mkdir(parents=True)
(provider / "__init__.py").write_text(PROVIDER_SOURCE.format(name=name), encoding="utf-8")
return provider
# ---------------------------------------------------------------------------
# Project-local providers
# ---------------------------------------------------------------------------
def test_project_dir_is_ignored_without_opt_in(tmp_path, monkeypatch):
"""A repo you merely cd into must not be able to offer a memory backend."""
_write_provider_dir(tmp_path / ".hermes" / "plugins", "projectmem")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
assert "projectmem" not in memory_plugins.list_memory_provider_names()
assert memory_plugins.find_provider_dir("projectmem") is None
def test_project_dir_is_discovered_when_opted_in(tmp_path, monkeypatch):
provider = _write_provider_dir(tmp_path / ".hermes" / "plugins", "projectmem")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
assert "projectmem" in memory_plugins.list_memory_provider_names()
assert memory_plugins.find_provider_dir("projectmem") == provider
def test_bundled_still_wins_over_project(tmp_path, monkeypatch):
"""Precedence here is bundled-first, the reverse of the general
PluginManager's later-wins order. A provider is activated by name, so a
directory dropped into the working tree must not be able to shadow a
shipped one and silently redirect the agent's memory."""
_write_provider_dir(tmp_path / ".hermes" / "plugins", "honcho")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
resolved = memory_plugins.find_provider_dir("honcho")
assert resolved == Path(memory_plugins.__file__).parent / "honcho"
# ---------------------------------------------------------------------------
# Pip entry-point providers
# ---------------------------------------------------------------------------
def test_entry_point_provider_is_listed(entry_points, tmp_path, monkeypatch):
"""list_memory_provider_names() fills the dashboard's memory.provider
dropdown. Enumerating entry points reads distribution metadata without
executing any of it, so this stays safe to call at import time."""
entry_points.append(FakeEntryPoint("pipmem", "pipmem_pkg"))
assert "pipmem" in memory_plugins.list_memory_provider_names()
def test_find_provider_dir_resolves_a_package_entry_point(entry_points, tmp_path, monkeypatch):
"""Without a directory, a pip-installed provider silently loses its
dashboard config panel and its `hermes <provider>` subcommands — both are
read from disk rather than imported."""
package = tmp_path / "pipmem_pkg"
package.mkdir()
(package / "__init__.py").write_text(PROVIDER_SOURCE.format(name="pipmem"), encoding="utf-8")
(package / "config_schema.py").write_text("CONFIG_SCHEMA = None\n", encoding="utf-8")
monkeypatch.syspath_prepend(str(tmp_path))
entry_points.append(FakeEntryPoint("pipmem", "pipmem_pkg:register"))
assert memory_plugins.find_provider_dir("pipmem") == package
def test_resolving_an_entry_point_does_not_import_it(entry_points, tmp_path, monkeypatch):
"""Discovery runs before the operator has chosen a provider. Importing
every installed candidate would execute third-party code on the strength of
a package merely being present."""
package = tmp_path / "sideeffect_pkg"
package.mkdir()
(package / "__init__.py").write_text(
textwrap.dedent(
"""\
import pathlib
pathlib.Path(__file__).with_name("IMPORTED").write_text("x")
"""
),
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
entry_points.append(FakeEntryPoint("sideeffect", "sideeffect_pkg"))
assert memory_plugins.find_provider_dir("sideeffect") == package
assert not (package / "IMPORTED").exists()
assert "sideeffect_pkg" not in sys.modules
def test_bare_module_entry_point_has_no_directory(entry_points, tmp_path, monkeypatch):
"""A single-file provider has nowhere to put a sibling config_schema.py, so
it resolves to None rather than handing back the whole site-packages root."""
(tmp_path / "flatmem.py").write_text(PROVIDER_SOURCE.format(name="flatmem"), encoding="utf-8")
monkeypatch.syspath_prepend(str(tmp_path))
entry_points.append(FakeEntryPoint("flatmem", "flatmem"))
assert memory_plugins.find_provider_dir("flatmem") is None
# ---------------------------------------------------------------------------
# Registration surface
# ---------------------------------------------------------------------------
def test_a_secondary_registration_cannot_cost_the_provider(tmp_path, monkeypatch):
"""register_auxiliary_task used to raise AttributeError on the collector —
which the loader caught, discarded the registered provider, and replaced
with a bare second instance built by the subclass scan. A silent downgrade
that looked like success."""
plugins_root = tmp_path / "plugins"
provider = _write_provider_dir(plugins_root, "auxmem")
(provider / "__init__.py").write_text(
PROVIDER_SOURCE.format(name="auxmem").replace(
" ctx.register_memory_provider(Provider())\n",
" instance = Provider()\n"
" instance.marked = True\n"
" ctx.register_memory_provider(instance)\n"
" ctx.register_auxiliary_task(\n"
" 'auxmem_filter', display_name='Aux', description='d'\n"
" )\n",
),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
loaded = memory_plugins.load_memory_provider("auxmem")
assert loaded is not None
assert loaded.name == "auxmem"
# The instance register() handed over, not a replacement.
assert getattr(loaded, "marked", False)
def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
"""Memory providers are activated by naming them in memory.provider. Using
a real PluginContext for secondary registrations must not start also
requiring the plugin in plugins.enabled — that would break every existing
user-installed provider."""
_write_provider_dir(tmp_path / "plugins", "gatedmem")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert memory_plugins.load_memory_provider("gatedmem") is not None
@@ -0,0 +1,51 @@
"""Tests for Hindsight's declared config surface."""
from plugins.memory.config_schema import (
KIND_SECRET,
KIND_SELECT,
get_provider_config_schema,
)
def test_hindsight_is_declared():
provider = get_provider_config_schema("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
assert {field.key for field in provider.fields} == {
"mode",
"api_key",
"api_url",
"bank_id",
"recall_budget",
}
def test_fields_are_all_inline():
provider = get_provider_config_schema("hindsight")
assert provider is not None
# Hindsight is simple enough to render fully in the compact panel, so it
# never grows a Full config… modal.
assert all(field.inline for field in provider.fields)
def test_mode_gating_is_expressed_as_select_options():
provider = get_provider_config_schema("hindsight")
assert provider is not None
mode = next(field for field in provider.fields if field.key == "mode")
assert mode.kind == KIND_SELECT
assert mode.allowed_values() == {"cloud", "local_external"}
# local_embedded is intentionally unsupported on desktop.
assert "local_embedded" not in mode.allowed_values()
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("hindsight")
assert provider is not None
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"
@@ -0,0 +1,80 @@
"""Regression tests: the embedded Hindsight profile env file carries the
plaintext ``HINDSIGHT_API_LLM_API_KEY`` and must be created/kept owner-only
(0600), and must not survive a failed post-write permission validation.
"""
import os
import stat
from pathlib import Path
import pytest
from plugins.memory.hindsight import (
_embedded_profile_env_path,
_materialize_embedded_profile_env,
)
@pytest.fixture(autouse=True)
def _isolated_home(tmp_path, monkeypatch):
isolated_home = tmp_path / "user-home"
monkeypatch.setattr(Path, "home", classmethod(lambda cls: isolated_home))
return isolated_home
_CONFIG = {
"profile": "hermes",
"llm_provider": "openai",
"llm_model": "gpt-4o-mini",
}
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
def test_fresh_profile_env_is_owner_only_despite_permissive_umask():
old_umask = os.umask(0o022)
try:
profile_env = _materialize_embedded_profile_env(
_CONFIG, llm_api_key="sk-hindsight-secret"
)
finally:
os.umask(old_umask)
assert profile_env.exists()
assert stat.S_IMODE(profile_env.stat().st_mode) == 0o600
assert "HINDSIGHT_API_LLM_API_KEY=sk-hindsight-secret\n" in profile_env.read_text(
encoding="utf-8"
)
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
def test_rewrite_tightens_existing_world_readable_profile_env():
profile_env = _embedded_profile_env_path(_CONFIG)
profile_env.parent.mkdir(parents=True)
profile_env.write_text("HINDSIGHT_API_LLM_API_KEY=stale\n", encoding="utf-8")
os.chmod(profile_env, 0o644)
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-current")
assert stat.S_IMODE(profile_env.stat().st_mode) == 0o600
assert "HINDSIGHT_API_LLM_API_KEY=sk-current\n" in profile_env.read_text(
encoding="utf-8"
)
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
def test_secret_file_removed_when_permission_validation_fails(monkeypatch):
"""If the post-write permission check cannot verify 0600, the plaintext
key file must not be left behind."""
import plugins.memory.hindsight as hs
def _fail_validation(profile_env):
raise PermissionError(f"not owner-only: {profile_env}")
monkeypatch.setattr(hs, "_validate_profile_env_permissions", _fail_validation)
with pytest.raises(PermissionError):
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-doomed")
assert not _embedded_profile_env_path(_CONFIG).exists(), (
"secret env file must be cleaned up when validation fails"
)
@@ -0,0 +1,56 @@
"""NousResearch/hermes-agent#7718 — actionable message when local_embedded
runtime (`hindsight-all`) is missing.
`local_embedded` imports `from hindsight import HindsightEmbedded`, provided
only by `hindsight-all`. When it's absent the provider disables itself; the
disable warning should point the user at the fix rather than just echoing
`No module named 'hindsight'`.
"""
import sys
import plugins.memory.hindsight as hs
from plugins.memory.hindsight import HindsightMemoryProvider, _local_runtime_hint
def test_hint_for_missing_hindsight_all():
hint = _local_runtime_hint("No module named 'hindsight'")
assert "hindsight-all" in hint
assert "hermes memory setup" in hint
assert sys.executable in hint
def test_hint_for_missing_hindsight_embed():
hint = _local_runtime_hint("No module named 'hindsight_embed.daemon_embed_manager'")
assert "hindsight-all" in hint
def test_no_hint_for_unrelated_runtime_error():
# e.g. the NumPy-on-old-CPU failure _check_local_runtime also guards against
assert _local_runtime_hint("Illegal instruction (NumPy SIMD)") == ""
assert _local_runtime_hint(None) == ""
# unavailable_reason() — surfaces the hint through the reachable path (#7718):
# is_available() gates initialize() out, so the hint must come from here.
def test_unavailable_reason_surfaces_hint_for_local_embedded(monkeypatch):
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "local_embedded"})
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (False, "No module named 'hindsight'"))
reason = HindsightMemoryProvider().unavailable_reason()
assert "hindsight-all" in reason
assert reason == reason.strip() # no leading/trailing whitespace
def test_unavailable_reason_empty_for_cloud(monkeypatch):
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "cloud"})
# Should not even probe the runtime for a cloud provider.
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (_ for _ in ()).throw(AssertionError("probed")))
assert HindsightMemoryProvider().unavailable_reason() == ""
def test_unavailable_reason_empty_when_runtime_present(monkeypatch):
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "local_embedded"})
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (True, None))
assert HindsightMemoryProvider().unavailable_reason() == ""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,260 @@
"""Tests for the Hindsight setup-wizard starter-template step."""
import json
from contextlib import contextmanager
import pytest
from plugins.memory.hindsight import templates as tpl
_CATALOG = {
"templates": [
{"id": "conversation", "name": "Conversation", "integrations": ["litellm", "hermes"],
"manifest_file": "templates/conversation.json"},
{"id": "coding-agent", "name": "Coding Agent", "integrations": ["claude-code"],
"manifest_file": "templates/coding-agent.json"},
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "integrations": ["hermes"],
"manifest_file": "templates/hermes-gateway-bot.json"},
]
}
def test_fetch_hermes_templates_filters_to_hermes(monkeypatch):
monkeypatch.setattr(tpl, "_get_json", lambda url: _CATALOG)
entries = tpl.fetch_hermes_templates("https://example/templates.json")
ids = [e["id"] for e in entries]
assert ids == ["conversation", "hermes-gateway-bot"] # coding-agent excluded
def test_fetch_manifest_resolves_relative_url(monkeypatch):
seen = {}
def _fake(url):
seen["url"] = url
return {"version": "1"}
monkeypatch.setattr(tpl, "_get_json", _fake)
tpl.fetch_manifest(
{"manifest_file": "templates/hermes-gateway-bot.json"},
"https://raw.example/data/templates.json",
)
assert seen["url"] == "https://raw.example/data/templates/hermes-gateway-bot.json"
def test_apply_template_posts_to_import_endpoint(monkeypatch):
captured = {}
@contextmanager
def _fake_open(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["auth"] = req.get_header("Authorization")
captured["body"] = json.loads(req.data.decode("utf-8"))
class _Resp:
def read(self):
return b""
yield _Resp()
monkeypatch.setattr(tpl, "open_credentialed_url", _fake_open)
tpl.apply_template("https://api.hindsight.vectorize.io/", "hermes", "hsk_abc", {"version": "1"})
assert captured["url"] == "https://api.hindsight.vectorize.io/v1/default/banks/hermes/import"
assert captured["method"] == "POST"
assert captured["auth"] == "Bearer hsk_abc"
assert captured["body"] == {"version": "1"}
def test_apply_template_omits_auth_when_no_key(monkeypatch):
captured = {}
@contextmanager
def _fake_open(req, timeout=None):
captured["auth"] = req.get_header("Authorization")
class _Resp:
def read(self):
return b""
yield _Resp()
monkeypatch.setattr(tpl, "open_credentialed_url", _fake_open)
tpl.apply_template("http://localhost:8888", "hermes", None, {"version": "1"})
assert captured["auth"] is None
def _select_returning(idx):
def _select(title, items, default=0, cancel_returns=None):
return idx
return _select
def _select_seq(*returns):
it = iter(returns)
def _select(title, items, default=0, cancel_returns=None):
return next(it)
return _select
def test_supported_for_mode():
assert tpl.supported_for_mode("cloud") is True
assert tpl.supported_for_mode("local_external") is True
assert tpl.supported_for_mode("local_embedded") is False
assert tpl.supported_for_mode("local") is False
def test_run_template_step_applies_selected(monkeypatch):
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
])
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: False)
applied = {}
monkeypatch.setattr(tpl, "apply_template",
lambda api_url, bank_id, api_key, manifest: applied.update(bank=bank_id))
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
)
assert result == "hermes-gateway-bot"
assert applied["bank"] == "hermes"
def test_run_template_step_blank_selection_skips(monkeypatch):
entries = [{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"}]
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: entries)
called = {"applied": False}
monkeypatch.setattr(tpl, "apply_template",
lambda *a, **k: called.update(applied=True))
# index len(entries) == the "Blank" row
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(len(entries)), cancelled=-1, log=lambda *_: None,
)
assert result is None
assert called["applied"] is False
def test_run_template_step_no_templates_is_noop(monkeypatch):
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [])
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
)
assert result is None
def test_run_template_step_swallows_fetch_errors(monkeypatch):
def _boom(url=None):
raise RuntimeError("network down")
monkeypatch.setattr(tpl, "fetch_hermes_templates", _boom)
# must not raise
assert tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
) is None
def test_run_template_step_swallows_apply_errors(monkeypatch):
# gap 1: a failed apply (e.g. 401 for an OAuth-only user) must not crash setup.
import urllib.error
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
])
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: False)
def _raise(*a, **k):
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
monkeypatch.setattr(tpl, "apply_template", _raise)
logs = []
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key=None,
select=_select_returning(0), cancelled=-1, log=logs.append,
)
assert result is None
assert any("Could not apply" in line for line in logs)
def _fake_open(payload):
@contextmanager
def _cm(req, timeout=None):
class _Resp:
def read(self):
return json.dumps(payload).encode("utf-8")
yield _Resp()
return _cm
def test_probe_existing_customization_true_when_bank_has_config(monkeypatch):
monkeypatch.setattr(tpl, "open_credentialed_url",
_fake_open({"version": "1", "bank": {"reflect_mission": "x"}}))
assert tpl.probe_existing_customization("https://api", "hermes", "k") is True
def test_probe_existing_customization_false_when_empty(monkeypatch):
monkeypatch.setattr(tpl, "open_credentialed_url",
_fake_open({"version": "1"}))
assert tpl.probe_existing_customization("https://api", "hermes", "k") is False
def test_probe_existing_customization_false_on_error(monkeypatch):
def _boom(req, timeout=None):
raise OSError("no bank")
monkeypatch.setattr(tpl, "open_credentialed_url", _boom)
assert tpl.probe_existing_customization("https://api", "missing", None) is False
def _wire_apply(monkeypatch, customized):
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
])
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: customized)
called = {"applied": False}
monkeypatch.setattr(tpl, "apply_template", lambda *a, **k: called.update(applied=True))
return called
def test_warns_and_keeps_existing_when_declined(monkeypatch):
called = _wire_apply(monkeypatch, customized=True)
# first select = pick template (0); second select = confirm -> "Keep existing" (1)
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_seq(0, 1), cancelled=-1, log=lambda *_: None,
)
assert result is None
assert called["applied"] is False
def test_warns_then_applies_when_confirmed(monkeypatch):
called = _wire_apply(monkeypatch, customized=True)
# pick template (0), confirm "Apply" (0)
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_seq(0, 0), cancelled=-1, log=lambda *_: None,
)
assert result == "hermes-gateway-bot"
assert called["applied"] is True
def test_fresh_bank_skips_the_warning(monkeypatch):
called = _wire_apply(monkeypatch, customized=False)
# only ONE select call (no confirm) — _select_seq with a single value proves it
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_seq(0), cancelled=-1, log=lambda *_: None,
)
assert result == "hermes-gateway-bot"
assert called["applied"] is True
@@ -0,0 +1,139 @@
"""Regression tests for #57682 — holographic auto_extract harvested
context-compaction handoff summaries into fact_store, and ran even when
configured off.
Two compounding defects:
1. Gate: the plugin's config schema declares ``auto_extract`` as a string enum
(``"false"``/``"true"``), and the ``on_session_end`` gate used plain
truthiness — ``not "false"`` is ``False`` — so extraction ran despite being
explicitly configured off.
2. Eligibility: ``_auto_extract_facts`` scanned every ``role == "user"``
message. Compaction handoff summaries can be inserted as ``role="user"``
messages, and their prose reliably matches the decision patterns
(``we decided/agreed/chose``, ``the project uses/needs/requires``), so the
compactor's own output was stored as a durable ``project`` fact on every
session rollover that followed a compaction.
"""
import pytest
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
SUMMARY_PREFIX,
_MERGED_PRIOR_CONTEXT_HEADER,
_MERGED_SUMMARY_DELIMITER,
is_compaction_summary_message,
)
from plugins.memory.holographic import HolographicMemoryProvider
def _make_provider(tmp_path, **config):
base = {"db_path": str(tmp_path / "memory_store.db"), "hrr_dim": 64}
base.update(config)
provider = HolographicMemoryProvider(config=base)
provider.initialize(session_id="test-session")
return provider
def _fact_contents(provider):
return [f["content"] for f in provider._store.list_facts(limit=100)]
def _user(content, **extra):
msg = {"role": "user", "content": content}
msg.update(extra)
return msg
DECISION_MSG = "we decided to use PostgreSQL for the persistence layer"
SUMMARY_MSG = (
f"{SUMMARY_PREFIX}\n## Historical Task Snapshot\n"
"The project uses a kanban board for all dispatch. "
"We agreed to route reviews through the fan-in consumer."
)
# ---------------------------------------------------------------------------
# Defect 1 — string-boolean gate
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("off_value", [False, "false", "False", "no", "off", "0", None, ""])
def test_auto_extract_off_values_disable_extraction(tmp_path, off_value):
provider = _make_provider(tmp_path, auto_extract=off_value)
provider.on_session_end([_user(DECISION_MSG)])
assert _fact_contents(provider) == []
provider.shutdown()
# ---------------------------------------------------------------------------
# Defect 2 — compaction summaries harvested as facts
# ---------------------------------------------------------------------------
def test_compaction_summary_not_harvested(tmp_path):
"""The exact failure mode from #57682: summary prose matches the decision
patterns but must not become a fact."""
provider = _make_provider(tmp_path, auto_extract=True)
provider.on_session_end([_user(SUMMARY_MSG)])
assert _fact_contents(provider) == []
provider.shutdown()
def test_metadata_marked_summary_not_harvested(tmp_path):
"""In-process summaries carry COMPRESSED_SUMMARY_METADATA_KEY even if a
future prefix rewrite changes the content sentinel."""
provider = _make_provider(tmp_path, auto_extract=True)
marked = _user(DECISION_MSG, **{COMPRESSED_SUMMARY_METADATA_KEY: True})
provider.on_session_end([marked])
assert _fact_contents(provider) == []
provider.shutdown()
def test_merged_into_tail_summary_suffix_not_harvested_prefix_content_ignored(tmp_path):
"""Merge-into-tail summaries embed the handoff prefix after the delimiter,
not at the start of the message. The wrapped pre-delimiter segment here has
no fact-pattern match, so nothing is harvested from either side."""
provider = _make_provider(tmp_path, auto_extract=True)
merged = _user(
f"{_MERGED_PRIOR_CONTEXT_HEADER}\nplease fix the login bug\n"
f"{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}"
)
provider.on_session_end([merged])
assert _fact_contents(provider) == []
provider.shutdown()
def test_real_user_messages_still_extracted_alongside_summary(tmp_path):
"""The guard must skip only the summary, not suppress extraction for the
genuine user turns around it."""
provider = _make_provider(tmp_path, auto_extract=True)
provider.on_session_end(
[
_user(SUMMARY_MSG),
_user("I prefer tabs over spaces for indentation"),
]
)
facts = _fact_contents(provider)
assert len(facts) == 1
assert "tabs over spaces" in facts[0]
provider.shutdown()
# ---------------------------------------------------------------------------
# is_compaction_summary_message — public helper contract
# ---------------------------------------------------------------------------
def test_helper_detects_prefix_metadata_and_merged_forms():
assert is_compaction_summary_message(_user(SUMMARY_MSG))
assert is_compaction_summary_message(
_user("anything", **{COMPRESSED_SUMMARY_METADATA_KEY: True})
)
assert is_compaction_summary_message(
_user(f"prior tail\n{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}")
)
assert not is_compaction_summary_message(_user(DECISION_MSG))
assert not is_compaction_summary_message(_user(""))
@@ -0,0 +1,240 @@
"""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}"
# ---------------------------------------------------------------------------
# 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()
# ---------------------------------------------------------------------------
# Loop-invariant encode hoists (perf) — search/probe/related must encode
# constant vectors ONCE per call, not once per candidate/row.
# encode_text/encode_atom are deterministic (SHA-256 counter blocks), so the
# hoisted vectors are bit-identical to the per-iteration values they replace.
# ---------------------------------------------------------------------------
from plugins.memory.holographic import holographic as hrr
@pytest.fixture
def hoisted_retriever(tmp_path):
"""30 facts with HRR vectors, default dim (smaller dims trip an
inhomogeneous-shape edge in the fact encoder).
NOTE: a real tmp_path db, NOT ":memory:" — MemoryStore resolves the
path and shares one process-wide connection per file, so ":memory:"
becomes a literal ./:memory: file that leaks state across runs (and
the NULL-vector test below would permanently corrupt it)."""
store = MemoryStore(str(tmp_path / "hoist_store.db"))
for i in range(30):
store.add_fact(
content=f"deploy target {i} setting alpha beta gamma option {i % 7}",
category="fact" if i % 2 else "preference",
tags=f"entity_{i % 5} deploy",
)
retriever = FactRetriever(store=store)
yield retriever
store.close()
def _counting_spy(monkeypatch, attr):
calls = []
real = getattr(hrr, attr)
def wrapper(*args, **kwargs):
calls.append(args)
return real(*args, **kwargs)
monkeypatch.setattr(hrr, attr, wrapper)
return calls
def test_encode_functions_are_deterministic():
"""Soundness premise of the hoists: same input -> identical vector."""
import numpy as np
assert np.array_equal(hrr.encode_text("deploy target", 1024),
hrr.encode_text("deploy target", 1024))
assert np.array_equal(hrr.encode_atom("__hrr_role_content__", 1024),
hrr.encode_atom("__hrr_role_content__", 1024))
def test_search_encodes_query_vector_once(hoisted_retriever, monkeypatch):
calls = _counting_spy(monkeypatch, "encode_text")
results = hoisted_retriever.search("deploy target setting")
assert results # the HRR path actually engaged
assert len(calls) == 1, (
f"query vector encoded {len(calls)}x in one search() — "
"loop-invariant hoist regressed"
)
def test_search_results_bit_identical_to_unhoisted(hoisted_retriever):
"""Parity: hoisted search() must produce the exact pre-fix results.
Replicates the pre-fix loop (query vector encoded per candidate) as the
reference and compares full scored output for exact equality.
"""
r = hoisted_retriever
query = "deploy target setting"
new_results = r.search(query)
# --- pre-fix reference ---
candidates = r._fts_candidates(query, None, 0.3, 10 * 3)
query_tokens = r._tokenize(query)
scored = []
for fact in candidates:
content_tokens = r._tokenize(fact["content"])
tag_tokens = r._tokenize(fact.get("tags", ""))
all_tokens = content_tokens | tag_tokens
jaccard = r._jaccard_similarity(query_tokens, all_tokens)
fts_score = fact.get("fts_rank", 0.0)
if r.hrr_weight > 0 and fact.get("hrr_vector"):
fact_vec = hrr.bytes_to_phases(fact["hrr_vector"])
query_vec = hrr.encode_text(query, r.hrr_dim) # per-candidate
hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0
else:
hrr_sim = 0.5
relevance = (r.fts_weight * fts_score
+ r.jaccard_weight * jaccard
+ r.hrr_weight * hrr_sim)
fact["score"] = relevance * fact["trust_score"]
scored.append(fact)
scored.sort(key=lambda x: x["score"], reverse=True)
old_results = scored[:10]
for fact in old_results:
fact.pop("hrr_vector", None)
assert new_results == old_results
def test_related_encodes_role_atoms_once(hoisted_retriever, monkeypatch):
calls = _counting_spy(monkeypatch, "encode_atom")
results = hoisted_retriever.related("entity_1")
assert results
role_calls = [a for a in calls
if a and str(a[0]).startswith("__hrr_role_")]
assert len(role_calls) == 2, (
f"role atoms encoded {len(role_calls)}x in one related() — "
"expected exactly 2 (role_entity + role_content, hoisted)"
)
def test_probe_encodes_role_atom_once(hoisted_retriever, monkeypatch):
calls = _counting_spy(monkeypatch, "encode_atom")
results = hoisted_retriever.probe("entity_1")
assert results
role_content_calls = [a for a in calls
if a and a[0] == "__hrr_role_content__"]
assert len(role_content_calls) == 1, (
f"role_content atom encoded {len(role_content_calls)}x in one "
"probe() — loop-invariant hoist regressed"
)
def test_search_without_vectors_never_encodes(hoisted_retriever, monkeypatch):
"""Migrated DBs can have FTS candidates with NULL hrr_vector
(MemoryStore._init_db adds the column without backfilling existing
facts). The lazy hoist must not encode a query vector nothing will
use — pre-fix main encoded only beneath fact.get('hrr_vector')."""
store = hoisted_retriever.store
store._conn.execute("UPDATE facts SET hrr_vector = NULL")
store._conn.commit()
calls = _counting_spy(monkeypatch, "encode_text")
results = hoisted_retriever.search("deploy target setting")
assert results # candidates exist; neutral hrr_sim=0.5 path
assert calls == [], (
f"encode_text called {len(calls)}x with zero vector candidates — "
"lazy hoist regressed to eager"
)
@@ -0,0 +1,130 @@
"""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_release_all_under_closes_connections_inside_directory_only(tmp_path):
"""Regression test for #88347 — profile delete must break refcounted handles.
``close()`` alone can never free a database held by a live agent (refs >= 1),
and on Windows an open SQLite handle makes rmtree of the profile directory
fail with WinError 32. ``release_all_under`` force-closes exactly the shared
connections under the doomed directory and leaves everything else alone.
"""
from plugins.memory.holographic.store import MemoryStore
profile_dir = tmp_path / "profiles" / "default-2"
profile_dir.mkdir(parents=True)
inside = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
outside = MemoryStore(db_path=tmp_path / "other" / "memory_store.db", hrr_dim=64)
inside_conn = inside._conn
outside_conn = outside._conn
# Both live before the release; the inside one is held "forever" (refs=1,
# like a live agent's provider — nobody calls close()).
inside_conn.execute("SELECT 1").fetchone()
outside_conn.execute("SELECT 1").fetchone()
try:
released = MemoryStore.release_all_under(profile_dir)
assert released == 1
# The doomed connection is really closed despite refs > 0 ...
with pytest.raises(sqlite3.ProgrammingError):
inside_conn.execute("SELECT 1")
# ... and the registry entry is gone, so a second release finds
# nothing (the CLI-process no-op case).
assert str((profile_dir / "memory_store.db").resolve()) not in MemoryStore._shared
assert MemoryStore.release_all_under(profile_dir) == 0
# A new store on the same path reopens fresh instead of reusing
# the dead connection.
reopened = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
reopened._conn.execute("SELECT 1").fetchone()
# The sibling outside the directory is untouched.
outside_conn.execute("SELECT 1").fetchone()
finally:
inside.close()
reopened.close()
outside.close()
def test_stale_holder_close_does_not_evict_fresh_registry_entry(tmp_path):
"""Follow-up to #88347 — a stale holder's late ``close()`` must be inert.
After ``release_all_under`` force-closes a profile's connection, a store
re-created on the same path registers a FRESH shared entry under the same
key. If the stale holder (whose entry was force-closed) then calls
``close()``, it must not pop the fresh entry out of the registry — that
would let a third store open a SECOND connection to the same database and
silently reintroduce the multi-writer contention the registry prevents.
"""
from plugins.memory.holographic.store import MemoryStore
profile_dir = tmp_path / "profiles" / "default-2"
profile_dir.mkdir(parents=True)
db_path = profile_dir / "memory_store.db"
stale = MemoryStore(db_path=db_path, hrr_dim=64)
assert MemoryStore.release_all_under(profile_dir) == 1
fresh = MemoryStore(db_path=db_path, hrr_dim=64)
key = fresh._key
fresh_entry = MemoryStore._shared[key]
# The stale holder's late close must leave the fresh entry registered...
stale.close()
assert MemoryStore._shared.get(key) is fresh_entry
# ...and a third store must attach to the SAME shared connection.
third = MemoryStore(db_path=db_path, hrr_dim=64)
try:
assert third._conn is fresh._conn
finally:
third.close()
fresh.close()
# Normal last-holder close still evicts its own entry.
assert key not in MemoryStore._shared
@@ -0,0 +1,227 @@
"""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 == {}
@@ -0,0 +1,125 @@
"""Regression tests for #76414: `hermes honcho peers` showed "(not set)"
for every non-default profile.
_all_profile_host_configs() built the per-profile host key inline as
f"{HOST}.{profile}" ("hermes.work") while every other reader/writer —
profile_host_key(), resolve_active_host(), honcho status/enable/sync and
the runtime plugin — uses the underscore form ("hermes_work"). The lookup
always missed, so cmd_peers fell back to "(not set)" and leaked the raw
malformed key into the AI-peer column.
These tests drive the real cmd_peers / _all_profile_host_configs against
a real honcho.json (temp HERMES_HOME, no network).
"""
import io
import json
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import plugins.memory.honcho.cli as honcho_cli
@pytest.fixture
def honcho_home(tmp_path, monkeypatch):
cfg = {
"peerName": "alice",
"hosts": {
"hermes": {"peerName": "alice", "aiPeer": "hermes"},
"hermes_work": {"peerName": "alice", "aiPeer": "hermes"},
"hermes_my_profile": {"peerName": "bob", "aiPeer": "hermes"},
},
}
path = tmp_path / "honcho.json"
path.write_text(json.dumps(cfg))
monkeypatch.setattr(honcho_cli, "_config_path", lambda: path)
return tmp_path
def _peers_output(profiles):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
honcho_cli.cmd_peers(SimpleNamespace())
finally:
sys.stdout = old
return buf.getvalue()
class TestAllProfileHostConfigs:
def test_profile_host_keys_match_writer_form(self, honcho_home, monkeypatch):
"""The lookup key must be profile_host_key()'s underscore form —
the same one honcho sync/enable/status and the runtime write to."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
)
rows = honcho_cli._all_profile_host_configs()
by_name = {name: (host, block) for name, host, block in rows}
host, block = by_name["work"]
assert host == "hermes_work" # not "hermes.work"
assert block.get("peerName") == "alice" # the populated block was found
def test_sanitized_profile_names_resolve(self, honcho_home, monkeypatch):
"""Profiles needing sanitization (dots/spaces in the name) also
resolve — profile_host_key maps 'my.profile' -> 'hermes_my_profile';
the inline dot form never could."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"),
SimpleNamespace(name="my.profile")],
)
rows = honcho_cli._all_profile_host_configs()
by_name = {name: block for name, _, block in rows}
assert by_name["my.profile"].get("peerName") == "bob"
def test_legacy_dot_form_host_key_still_readable(self, honcho_home, monkeypatch):
"""Back-compat: honcho.json files with LEGACY dot-form host keys
("hermes.work") must keep working — the README promises those keys
stay readable, and _host_block() exists precisely for that fallback.
A bare hosts.get(profile_host_key(...)) would regress them."""
path = honcho_home / "honcho.json"
cfg = json.loads(path.read_text())
del cfg["hosts"]["hermes_work"]
cfg["hosts"]["hermes.work"] = {"peerName": "carol", "aiPeer": "hermes"}
path.write_text(json.dumps(cfg))
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
)
rows = honcho_cli._all_profile_host_configs()
by_name = {name: block for name, _, block in rows}
assert by_name["work"].get("peerName") == "carol"
class TestCmdPeers:
def test_peers_shows_populated_identity_not_host_key_leak(
self, honcho_home, monkeypatch):
"""Issue #76414's visible symptom: the AI-peer column showed the
raw malformed key 'hermes.work' (or '(not set)')."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
)
out = _peers_output(SimpleNamespace())
assert "hermes.work" not in out
assert "(not set)" not in out
# work row shows the populated block's values
work_line = [l for l in out.splitlines() if l.strip().startswith("work")][0]
assert "alice" in work_line and "hermes" in work_line
def test_peers_falls_back_cleanly_when_block_missing(
self, honcho_home, monkeypatch):
"""A profile with no host block still falls back to the top-level
peerName and the (well-formed) host key — not a crash or a leak."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="new")],
)
out = _peers_output(SimpleNamespace())
assert "hermes.new" not in out # well-formed key, no dot-form leak
new_line = [l for l in out.splitlines() if l.strip().startswith("new")][0]
assert "alice" in new_line # top-level peerName fallback
@@ -0,0 +1,90 @@
"""Tests for Honcho's declared config surface."""
from plugins.memory.config_schema import (
KIND_BOOL,
KIND_JSON,
KIND_NUMBER,
KIND_SECRET,
KIND_SELECT,
STORAGE_HONCHO_HOST_BLOCK,
get_provider_config_schema,
)
# The curated set shown in the compact panel; everything else lives in the modal.
INLINE_KEYS = {
"apiKey",
"baseUrl",
"environment",
"workspace",
"peerName",
"aiPeer",
"sessionStrategy",
}
def test_honcho_is_declared():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert provider.label == "Honcho"
assert provider.storage == STORAGE_HONCHO_HOST_BLOCK
# Field keys are unique, and the curated inline set is present.
keys = [field.key for field in provider.fields]
assert len(keys) == len(set(keys))
assert INLINE_KEYS <= set(keys)
def test_inline_fields_are_the_curated_subset():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert {field.key for field in provider.inline_fields()} == INLINE_KEYS
# The modal-only fields are a non-empty remainder.
non_inline = {f.key for f in provider.fields} - INLINE_KEYS
assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline
def test_declares_the_new_field_kinds():
provider = get_provider_config_schema("honcho")
assert provider is not None
by_key = {f.key: f for f in provider.fields}
assert by_key["saveMessages"].kind == KIND_BOOL
assert by_key["dialecticMaxChars"].kind == KIND_NUMBER
assert by_key["userPeerAliases"].kind == KIND_JSON
assert by_key["recallMode"].allowed_values() == {"hybrid", "context", "tools"}
assert by_key["observationMode"].allowed_values() == {"directional", "unified"}
def test_selects_constrain_their_values():
provider = get_provider_config_schema("honcho")
assert provider is not None
environment = next(f for f in provider.fields if f.key == "environment")
assert environment.kind == KIND_SELECT
# Honcho SDK only accepts local/production; "demo" is not a valid environment.
assert environment.allowed_values() == {"production", "local"}
strategy = next(f for f in provider.fields if f.key == "sessionStrategy")
assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"}
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("honcho")
assert provider is not None
api_key = next(f for f in provider.fields if f.key == "apiKey")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HONCHO_API_KEY"
def test_root_scoped_fields_are_exactly_the_global_ones():
provider = get_provider_config_schema("honcho")
assert provider is not None
scopes = {f.key: f.scope for f in provider.fields}
root_keys = {k for k, scope in scopes.items() if scope == "root"}
# baseUrl, timeout and sessions live at the config root in Honcho's schema;
# everything else is per-profile host-scoped.
assert root_keys == {"baseUrl", "timeout", "sessions"}
+680
View File
@@ -0,0 +1,680 @@
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
import copy
import importlib
import json
import os
import sys
import types
from dataclasses import dataclass, field
from types import SimpleNamespace
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_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_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!"}
@dataclass
class _FakeMem0State:
factory_registrations: list = field(default_factory=list)
from_config_calls: int = 0
clients: list = field(default_factory=list)
requests: list = field(default_factory=list)
def _install_fake_mem0(monkeypatch):
"""Install a small mem0 2.0.10-shaped surface for OSS backend tests."""
state = _FakeMem0State()
class BaseLlmConfig:
def __init__(
self,
model=None,
temperature=0.1,
api_key=None,
max_tokens=2000,
top_p=0.1,
top_k=1,
enable_vision=False,
vision_details="auto",
reasoning_effort=None,
http_client_proxies=None,
is_reasoning_model=None,
**kwargs,
):
self.model = model
self.temperature = temperature
self.api_key = api_key
self.max_tokens = max_tokens
self.top_p = top_p
self.top_k = top_k
self.enable_vision = enable_vision
self.vision_details = vision_details
self.reasoning_effort = reasoning_effort
self.http_client_proxies = http_client_proxies
self.is_reasoning_model = is_reasoning_model
for name, value in kwargs.items():
setattr(self, name, value)
class OpenAIConfig(BaseLlmConfig):
def __init__(
self,
model=None,
temperature=0.1,
api_key=None,
max_tokens=2000,
top_p=0.1,
top_k=1,
enable_vision=False,
vision_details="auto",
reasoning_effort=None,
http_client_proxies=None,
is_reasoning_model=None,
openai_base_url=None,
models=None,
route="fallback",
openrouter_base_url=None,
site_url=None,
app_name=None,
store=None,
response_callback=None,
):
super().__init__(
model=model,
temperature=temperature,
api_key=api_key,
max_tokens=max_tokens,
top_p=top_p,
top_k=top_k,
enable_vision=enable_vision,
vision_details=vision_details,
reasoning_effort=reasoning_effort,
http_client_proxies=http_client_proxies,
is_reasoning_model=is_reasoning_model,
)
self.openai_base_url = openai_base_url
self.models = models
self.route = route
self.openrouter_base_url = openrouter_base_url
self.site_url = site_url
self.app_name = app_name
self.store = store
self.response_callback = response_callback
class LLMBase:
def __init__(self, config=None):
self.config = config or BaseLlmConfig()
if not hasattr(self.config, "model"):
raise ValueError("Configuration must have a 'model' attribute")
def _get_supported_params(self, **kwargs):
if self.config.is_reasoning_model:
return {
name: kwargs[name]
for name in ("messages", "response_format", "tools", "tool_choice")
if name in kwargs
}
params = {
"temperature": self.config.temperature,
"top_p": self.config.top_p,
"max_tokens": self.config.max_tokens,
}
params.update(kwargs)
return params
class OpenAILLM(LLMBase):
@staticmethod
def _parse_response(response, tools):
if not tools:
return response.choices[0].message.content
parsed = {
"content": response.choices[0].message.content,
"tool_calls": [],
}
for tool_call in response.choices[0].message.tool_calls or []:
parsed["tool_calls"].append(
{
"name": tool_call.function.name,
"arguments": json.loads(tool_call.function.arguments),
}
)
return parsed
class Factory:
provider_to_class = {
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
"ollama": ("mem0.llms.openai.OpenAILLM", BaseLlmConfig),
}
@classmethod
def register_provider(cls, name, class_path, config_class=None):
cls.provider_to_class[name] = (
class_path,
config_class or BaseLlmConfig,
)
state.factory_registrations.append((name, class_path, config_class))
@classmethod
def create(cls, provider_name, config=None, **kwargs):
class_path, config_class = cls.provider_to_class[provider_name]
if config is None:
config = config_class(**kwargs)
elif isinstance(config, dict):
config = config_class(**config)
module_name, class_name = class_path.rsplit(".", 1)
llm_class = getattr(importlib.import_module(module_name), class_name)
return llm_class(config)
class MemoryConfig:
def __init__(self, **config):
llm = config["llm"]
if llm["provider"] not in {"openai", "ollama"}:
raise ValueError(
f"Unsupported LLM provider: {llm['provider']}"
)
self.llm = SimpleNamespace(
provider=llm["provider"],
config=copy.deepcopy(llm.get("config", {})),
)
embedder = config["embedder"]
self.embedder = SimpleNamespace(
provider=embedder["provider"],
config=copy.deepcopy(embedder.get("config", {})),
)
vector_store = config["vector_store"]
self.vector_store = SimpleNamespace(
provider=vector_store["provider"],
config=copy.deepcopy(vector_store.get("config", {})),
)
self.version = config.get("version", "v1.1")
class Memory:
instances = []
def __init__(self, config):
self.config = config
self.llm = Factory.create(config.llm.provider, config.llm.config)
self.embedding_model = SimpleNamespace(
provider=config.embedder.provider,
config=config.embedder.config,
)
self.vector_store = SimpleNamespace(
provider=config.vector_store.provider,
config=config.vector_store.config,
)
type(self).instances.append(self)
@classmethod
def from_config(cls, config):
# This mirrors mem0 2.0.10: validation rejects the private provider
# before the factory gets a chance to resolve its registration.
state.from_config_calls += 1
return cls(MemoryConfig(**config))
class FakeOpenAI:
def __init__(self, *, api_key, base_url):
self.api_key = api_key
self.base_url = base_url
state.clients.append(self)
self.chat = SimpleNamespace(
completions=SimpleNamespace(create=self._create)
)
def _create(self, **params):
state.requests.append(params)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content="direct answer",
tool_calls=[
SimpleNamespace(
function=SimpleNamespace(
name="remember",
arguments='{"fact": "tea"}',
)
)
],
)
)
]
)
package_names = {
"mem0": types.ModuleType("mem0"),
"mem0.configs": types.ModuleType("mem0.configs"),
"mem0.configs.llms": types.ModuleType("mem0.configs.llms"),
"mem0.llms": types.ModuleType("mem0.llms"),
"mem0.utils": types.ModuleType("mem0.utils"),
"mem0.configs.base": types.ModuleType("mem0.configs.base"),
"mem0.configs.llms.base": types.ModuleType("mem0.configs.llms.base"),
"mem0.configs.llms.openai": types.ModuleType("mem0.configs.llms.openai"),
"mem0.llms.base": types.ModuleType("mem0.llms.base"),
"mem0.llms.openai": types.ModuleType("mem0.llms.openai"),
"mem0.utils.factory": types.ModuleType("mem0.utils.factory"),
"openai": types.ModuleType("openai"),
}
setattr(package_names["mem0"], "Memory", Memory)
setattr(package_names["mem0.configs.base"], "MemoryConfig", MemoryConfig)
setattr(package_names["mem0.configs.llms.base"], "BaseLlmConfig", BaseLlmConfig)
setattr(package_names["mem0.configs.llms.openai"], "OpenAIConfig", OpenAIConfig)
setattr(package_names["mem0.llms.base"], "LLMBase", LLMBase)
setattr(package_names["mem0.llms.openai"], "OpenAILLM", OpenAILLM)
setattr(package_names["mem0.utils.factory"], "LlmFactory", Factory)
setattr(package_names["openai"], "OpenAI", FakeOpenAI)
for name, module in package_names.items():
if name in {"mem0", "mem0.configs", "mem0.configs.llms", "mem0.llms", "mem0.utils"}:
module.__path__ = []
monkeypatch.setitem(sys.modules, name, module)
# The class-path registration imports this module after the fake mem0
# surface is installed, so it binds to the test doubles above.
monkeypatch.delitem(
sys.modules, "plugins.memory.mem0._openai_llm", raising=False
)
return state, Memory, Factory
class TestOSSBackend:
def _make(self):
memory = FakeOSSMemory()
backend = OSSBackend.__new__(OSSBackend)
backend._memory = memory
return backend, memory
def test_legacy_api_base_aliases_are_normalized_before_mem0_init(self, monkeypatch):
state, Memory, factory = _install_fake_mem0(monkeypatch)
raw = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "openai-sentinel",
"api_base": "https://llm.example/v1",
},
},
"embedder": {
"provider": "ollama",
"config": {"model": "nomic-embed-text", "api_base": "http://ollama:11434"},
},
"vector_store": {"provider": "qdrant", "config": {}},
}
before = copy.deepcopy(raw)
environment = dict(os.environ)
OSSBackend(raw)
assert len(Memory.instances) == 1
captured = Memory.instances[0].config
assert captured.llm.provider == "hermes_openai"
assert captured.llm.config["openai_base_url"] == "https://llm.example/v1"
assert captured.embedder.provider == "ollama"
assert captured.embedder.config["ollama_base_url"] == "http://ollama:11434"
assert "api_base" not in captured.llm.config
assert "api_base" not in captured.embedder.config
assert factory.provider_to_class["hermes_openai"][1].__name__ == "OpenAIConfig"
assert len(state.factory_registrations) == 1
assert state.from_config_calls == 0
assert raw == before
assert dict(os.environ) == environment
def test_direct_openai_uses_openai_credentials_and_request_shape(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
module = importlib.import_module("plugins.memory.mem0._openai_llm")
callback_calls = []
config = factory.provider_to_class["openai"][1](
model="gpt-5-mini",
api_key="configured-openai-sentinel",
openai_base_url="https://openai.example/v1",
models=["router-model"],
route="lowest-latency",
site_url="https://hermes.example",
app_name="Hermes",
store=True,
response_callback=lambda *args: callback_calls.append(args),
)
adapter = module.DirectOpenAILLM(config)
assert adapter.config.is_reasoning_model is True
tools = [
{
"type": "function",
"function": {"name": "remember", "parameters": {}},
}
]
result = adapter.generate_response(
[{"role": "user", "content": "remember tea"}],
response_format={"type": "json_object"},
tools=tools,
tool_choice="required",
)
assert len(state.clients) == 1
client = state.clients[0]
assert client.api_key == "configured-openai-sentinel"
assert client.base_url == "https://openai.example/v1"
request = state.requests[0]
assert request["model"] == "gpt-5-mini"
assert request["tools"] == tools
assert request["tool_choice"] == "required"
assert request["response_format"] == {"type": "json_object"}
assert request["store"] is True
assert "models" not in request
assert "route" not in request
assert "extra_headers" not in request
assert "temperature" not in request
assert "top_p" not in request
assert "max_tokens" not in request
assert result == {
"content": "direct answer",
"tool_calls": [{"name": "remember", "arguments": {"fact": "tea"}}],
}
assert len(callback_calls) == 1
assert callback_calls[0][0] is adapter
assert callback_calls[0][2] == request
def test_direct_openai_preserves_explicit_non_reasoning_override(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
config = factory.provider_to_class["openai"][1](
model="gpt-5-mini",
api_key="configured-openai-sentinel",
is_reasoning_model=False,
)
module = importlib.import_module("plugins.memory.mem0._openai_llm")
adapter = module.DirectOpenAILLM(config)
adapter.generate_response([{"role": "user", "content": "remember tea"}])
assert adapter.config.is_reasoning_model is False
request = state.requests[0]
assert request["temperature"] == 0.1
assert request["top_p"] == 0.1
assert request["max_tokens"] == 2000
def test_direct_openai_defaults_missing_model_to_reasoning_safe_mini(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
_install_fake_mem0(monkeypatch)
module = importlib.import_module("plugins.memory.mem0._openai_llm")
adapter = module.DirectOpenAILLM()
assert adapter.config.model == "gpt-5-mini"
assert adapter.config.is_reasoning_model is True
def test_direct_openai_uses_openai_environment_when_config_omits_values(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
monkeypatch.setenv("OPENAI_BASE_URL", "https://env-openai.example/v1")
module = importlib.import_module("plugins.memory.mem0._openai_llm")
config = factory.provider_to_class["openai"][1](model="gpt-5-mini")
adapter = module.DirectOpenAILLM(config)
assert len(state.clients) == 1
assert state.clients[0].api_key == "env-openai-sentinel"
assert state.clients[0].base_url == "https://env-openai.example/v1"
def test_missing_openai_key_fails_before_client_and_hides_router_secret(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
router_secret = "router-secret-sentinel"
monkeypatch.setenv("OPENROUTER_API_KEY", router_secret)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
module = importlib.import_module("plugins.memory.mem0._openai_llm")
config = factory.provider_to_class["openai"][1](
model="gpt-5-mini",
api_key=None,
)
with pytest.raises(ValueError) as exc_info:
module.DirectOpenAILLM(config)
assert "OpenAI API key" in str(exc_info.value)
assert router_secret not in str(exc_info.value)
assert state.clients == []
assert state.requests == []
def test_registration_is_idempotent_and_clients_keep_instance_config(self, monkeypatch):
state, Memory, factory = _install_fake_mem0(monkeypatch)
first = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "first-openai-sentinel",
"openai_base_url": "https://first.example/v1",
},
},
"embedder": {"provider": "ollama", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
second = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "second-openai-sentinel",
"openai_base_url": "https://second.example/v1",
},
},
"embedder": {"provider": "ollama", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
first_before = copy.deepcopy(first)
second_before = copy.deepcopy(second)
OSSBackend(first)
OSSBackend(second)
assert len(state.factory_registrations) == 1
assert factory.provider_to_class["hermes_openai"][0].endswith(
"_openai_llm.DirectOpenAILLM"
)
assert [
(client.api_key, client.base_url) for client in state.clients
] == [
("first-openai-sentinel", "https://first.example/v1"),
("second-openai-sentinel", "https://second.example/v1"),
]
assert len(Memory.instances) == 2
assert state.from_config_calls == 0
assert first == first_before
assert second == second_before
def test_ollama_bypasses_direct_openai_adapter(self, monkeypatch):
state, Memory, factory = _install_fake_mem0(monkeypatch)
raw = {
"llm": {
"provider": "ollama",
"config": {
"model": "llama3.1:8b",
"api_base": "http://ollama:11434",
},
},
"embedder": {
"provider": "ollama",
"config": {
"model": "nomic-embed-text",
"api_base": "http://ollama:11434",
},
},
"vector_store": {"provider": "qdrant", "config": {}},
}
before = copy.deepcopy(raw)
OSSBackend(raw)
assert len(Memory.instances) == 1
assert state.from_config_calls == 1
assert Memory.instances[0].config.llm.provider == "ollama"
assert Memory.instances[0].config.embedder.provider == "ollama"
assert "hermes_openai" not in factory.provider_to_class
assert state.clients == []
assert raw == before
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
# --- search ----------------------------------------------------------
# --- add / update / delete ------------------------------------------
# --- 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
@@ -0,0 +1,131 @@
"""Integration coverage for Hermes' pinned Mem0 OSS boundary."""
import copy
import os
from types import SimpleNamespace
import pytest
pytest.importorskip("mem0", reason="requires the existing mem0 extra")
def test_openai_backend_uses_real_mem0_config_and_factory(monkeypatch, tmp_path):
mem0_dir = tmp_path / "mem0"
monkeypatch.setenv("MEM0_DIR", str(mem0_dir))
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
import openai
from mem0.memory import main as memory_main
from mem0.utils.factory import LlmFactory
from plugins.memory.mem0._backend import OSSBackend
from plugins.memory.mem0._openai_llm import DirectOpenAILLM
clients = []
requests = []
class FakeOpenAI:
def __init__(self, *, api_key, base_url):
self.api_key = api_key
self.base_url = base_url
self.chat = SimpleNamespace(
completions=SimpleNamespace(create=self._create)
)
clients.append(self)
@staticmethod
def _create(**params):
requests.append(params)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content="direct answer",
tool_calls=None,
)
)
]
)
class DummyVectorStore:
pass
class DummyDB:
def __init__(self, _path):
pass
monkeypatch.setattr(
LlmFactory,
"provider_to_class",
dict(LlmFactory.provider_to_class),
)
monkeypatch.setattr(openai, "OpenAI", FakeOpenAI)
monkeypatch.setattr(
memory_main.EmbedderFactory,
"create",
lambda *_args, **_kwargs: object(),
)
monkeypatch.setattr(
memory_main.VectorStoreFactory,
"create",
lambda *_args, **_kwargs: DummyVectorStore(),
)
monkeypatch.setattr(memory_main, "SQLiteManager", DummyDB)
monkeypatch.setattr(memory_main, "MEM0_TELEMETRY", False)
monkeypatch.setattr(memory_main, "capture_event", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
OSSBackend,
"_recreate_collection_if_dims_changed",
staticmethod(lambda *_args, **_kwargs: None),
)
config = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "configured-openai-sentinel",
"openai_base_url": "https://openai.example/v1",
"models": ["router-model"],
"route": "lowest-latency",
},
},
"embedder": {
"provider": "ollama",
"config": {
"model": "nomic-embed-text",
"ollama_base_url": "http://ollama.example:11434",
"embedding_dims": 768,
},
},
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "mem0",
"path": str(tmp_path / "qdrant"),
},
},
}
original_config = copy.deepcopy(config)
environment = dict(os.environ)
backend = OSSBackend(config)
result = backend._memory.llm.generate_response(
[{"role": "user", "content": "remember tea"}]
)
assert isinstance(backend._memory.llm, DirectOpenAILLM)
assert len(clients) == 1
assert clients[0].api_key == "configured-openai-sentinel"
assert clients[0].base_url == "https://openai.example/v1"
assert requests == [
{
"model": "gpt-5-mini",
"messages": [{"role": "user", "content": "remember tea"}],
}
]
assert result == "direct answer"
assert config == original_config
assert dict(os.environ) == environment
@@ -0,0 +1,78 @@
"""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_vector_providers_have_required_keys(self):
for pid, p in VECTOR_PROVIDERS.items():
assert "label" in p
assert "default_config" in p
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_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)
+255
View File
@@ -0,0 +1,255 @@
"""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,
_prompt_api_key,
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_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["llm"]["config"]["is_reasoning_model"] is True
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_explicit_gpt_5_mini_is_reasoning_model(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-llm-model", "gpt-5-mini",
])
oss, _ = build_oss_config(flags)
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
assert oss["llm"]["config"]["is_reasoning_model"] is True
def test_custom_openai_model_is_not_forced_to_reasoning(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-llm-model", "gpt-5.2",
])
oss, _ = build_oss_config(flags)
assert oss["llm"]["config"]["model"] == "gpt-5.2"
assert "is_reasoning_model" not in oss["llm"]["config"]
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 oss["llm"]["config"]["ollama_base_url"] == "http://localhost:11434"
assert "is_reasoning_model" not in oss["llm"]["config"]
assert oss["embedder"]["config"]["ollama_base_url"] == "http://localhost:11434"
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
def test_preserves_non_ascii_existing_lines(self, tmp_path):
"""Existing non-ASCII .env content must survive the read-modify-write
as UTF-8 (the locale codec would crash/mangle it on Windows)."""
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
content = env_path.read_text(encoding="utf-8")
assert "PROXY_NOTE=café-zürich-完了" in content
assert "OPENAI_API_KEY=sk-test" in content
def test_updates_first_key_with_bom(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still be
matched/updated in place, not duplicated."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=old\n".encode("utf-8"))
_write_env(env_path, {"OPENAI_API_KEY": "new"})
content = env_path.read_text(encoding="utf-8")
assert content.count("OPENAI_API_KEY=") == 1
assert "OPENAI_API_KEY=new" in content
class TestPromptApiKey:
def test_existing_key_found_behind_bom(self, tmp_path, monkeypatch):
"""The masked-current-value lookup must see a key on the BOM'd first
line of a Notepad-edited .env instead of prompting from scratch."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=sk-existing\n".encode("utf-8"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
prompts: list[str] = []
def _fake_getpass(prompt):
prompts.append(prompt)
return ""
monkeypatch.setattr("plugins.memory.mem0._setup.getpass.getpass", _fake_getpass)
_prompt_api_key("OpenAI", "OPENAI_API_KEY", str(tmp_path))
assert len(prompts) == 1
assert "current: ...ting" in prompts[0]
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_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"
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
class TestConnectivityChecks:
def test_qdrant_path_writable(self, tmp_path):
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
assert ok is True
+411
View File
@@ -0,0 +1,411 @@
"""Tests for Mem0 v3 API — new tool names, paginated responses, update/delete tools."""
import json
import threading
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_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_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_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."
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
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
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_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):
entered = threading.Event()
release = threading.Event()
search_returned = threading.Event()
class SlowBackend(FakeBackend):
def search(self, query, *, filters, top_k=10, rerank=True):
entered.set()
try:
release.wait(30)
return super().search(
query, filters=filters, top_k=top_k, rerank=rerank
)
finally:
search_returned.set()
monkeypatch.setattr(mem0_plugin, "_PREFETCH_WAIT_SECS", 0.01)
provider = self._make_provider(
SlowBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
)
# DETERMINISTIC non-blocking witness — replaces `assert elapsed < 0.1`.
#
# The old form slept 0.2s in the backend and asserted prefetch returned
# in under 0.1s. That makes the OS scheduler part of the assertion: on
# a loaded box thread startup alone can eat the 100ms budget, so the
# inequality flips with nothing wrong in the code under test. Observed
# failing in a full-directory run of tests/plugins/memory.
#
# The real contract is that prefetch gives up on the slow backend
# instead of waiting for it. Assert it directly: the backend search is
# STILL PARKED (release unset, so `search_returned` cannot be set). If
# prefetch ever waited for the backend, the search would have returned
# first and this fails. No wall-clock constant.
assert provider.prefetch("where do I live?") == ""
assert entered.wait(30), "prefetch never reached the backend"
assert not search_returned.is_set(), (
"prefetch blocked on the slow backend: the backend search had "
"already returned by the time prefetch did"
)
release.set()
provider._prefetch_thread.join(timeout=30)
assert "lives in Berlin" in provider.prefetch("where do I live?")
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
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
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_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
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_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"
@@ -0,0 +1,250 @@
"""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."
)
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)
@@ -0,0 +1,68 @@
"""OpenViking endpoint always-blocked floor."""
import pytest
from plugins.memory.openviking import (
_OpenVikingEndpointError,
_local_openviking_bind,
_normalize_openviking_url,
_openviking_endpoint_is_always_blocked,
)
def test_openviking_blocks_metadata_endpoint():
with pytest.raises(_OpenVikingEndpointError, match="blocked metadata address"):
_normalize_openviking_url("http://169.254.169.254/")
def test_openviking_keeps_default_loopback():
assert _normalize_openviking_url("http://127.0.0.1:1933") == "http://127.0.0.1:1933"
@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"])
def test_openviking_bare_loopback_health_and_autostart_use_same_default_port(host):
endpoint = _normalize_openviking_url(host)
assert endpoint == f"http://{host}:1933"
assert _local_openviking_bind(endpoint) == (host, 1933)
def test_openviking_explicit_loopback_url_preserves_implicit_http_port():
assert _normalize_openviking_url("http://localhost") == "http://localhost"
def test_openviking_blocks_ecs_metadata_hostname():
with pytest.raises(_OpenVikingEndpointError, match="blocked metadata address"):
_normalize_openviking_url("http://metadata.google.internal/computeMetadata/v1/")
def test_openviking_rejects_endpoint_credentials_and_query():
with pytest.raises(_OpenVikingEndpointError, match="cannot contain user info"):
_normalize_openviking_url("https://user:secret@example.com?api_key=secret")
def test_openviking_validates_shorthand_ipv6_port():
assert _normalize_openviking_url("::1:1934") == "http://[::1]:1934"
with pytest.raises(_OpenVikingEndpointError, match="Port could not be cast"):
_normalize_openviking_url("::1:not-a-port")
def test_openviking_caches_safety_check_for_unchanged_endpoint(monkeypatch):
import tools.url_safety as url_safety
calls = []
_openviking_endpoint_is_always_blocked.cache_clear()
monkeypatch.setattr(
url_safety,
"is_always_blocked_url",
lambda value: calls.append(value) or False,
)
assert _normalize_openviking_url("https://openviking.example.test") == (
"https://openviking.example.test"
)
assert _normalize_openviking_url("https://openviking.example.test") == (
"https://openviking.example.test"
)
assert calls == ["https://openviking.example.test"]
_openviking_endpoint_is_always_blocked.cache_clear()
@@ -0,0 +1,355 @@
"""Optional peer identity must agree across setup, requests and memory writes."""
import json
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
import yaml
import plugins.memory.openviking as ov
@pytest.fixture(autouse=True)
def isolated_config(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
for key in (*ov._OPENVIKING_ENV_KEYS, "OPENVIKING_CLI_CONFIG_FILE"):
# Track absent keys too, so setup's direct environment writes are undone.
monkeypatch.setenv(key, "")
monkeypatch.delenv(key, raising=False)
@pytest.mark.parametrize("source", ["env", "yaml", "actor_peer_id", "agent_id"])
@pytest.mark.parametrize("peer", ["", "hermes", "work-assistant"])
def test_configured_peer_routing_is_preserved(tmp_path, monkeypatch, source, peer):
config = {}
if source == "env":
monkeypatch.setenv("OPENVIKING_AGENT", peer)
elif source == "yaml":
config["agent"] = peer
else:
path = tmp_path / "ovcli.conf"
path.write_text(
json.dumps({"url": "http://localhost:1933", source: peer}), encoding="utf-8"
)
config = {"use_ovcli_config": True, "ovcli_config_path": str(path)}
settings = ov._resolve_connection_settings(config)
client = ov._VikingClient("http://localhost:1933", agent=settings["agent"])
monkeypatch.setattr(client, "get", lambda *a, **kw: {"result": {"user": "alice"}})
provider = ov.OpenVikingMemoryProvider()
uri = provider._build_memory_uri("preferences", client=client)
assert settings["agent"] == peer
assert client._headers().get("X-OpenViking-Actor-Peer", "") == peer
prefix = f"peers/{peer}/" if peer else ""
assert uri.startswith(f"viking://user/alice/{prefix}memories/preferences/mem_")
def test_unconfigured_client_and_schema_do_not_supply_a_peer():
settings = ov._resolve_connection_settings({})
client = ov._VikingClient("http://localhost:1933")
schema = {
field["key"]: field
for field in ov.OpenVikingMemoryProvider().get_config_schema()
}
assert settings["agent"] == ""
assert schema["agent"]["default"] == ""
assert "X-OpenViking-Actor-Peer" not in client._headers()
assert "X-OpenViking-Actor-Peer" not in client._multipart_headers()
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_linked_profile_status_only_shows_a_configured_peer(tmp_path, peer):
path = tmp_path / "ovcli.conf"
path.write_text(
json.dumps({"url": "http://localhost:1933", "actor_peer_id": peer}),
encoding="utf-8",
)
display = ov.OpenVikingMemoryProvider().get_status_config({
"use_ovcli_config": True,
"ovcli_config_path": str(path),
})
if peer:
assert display["agent"] == peer
else:
assert "agent" not in display
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_memory_uri_uses_captured_peer_even_when_empty(monkeypatch, peer):
client = ov._VikingClient("http://localhost:1933", agent=peer)
monkeypatch.setattr(client, "get", lambda *a, **kw: {"result": {"user": "alice"}})
provider = ov.OpenVikingMemoryProvider()
provider._agent = "later-peer"
uri = provider._build_memory_uri("preferences", client=client)
prefix = f"peers/{peer}/" if peer else ""
assert uri.startswith(f"viking://user/alice/{prefix}memories/preferences/mem_")
assert "later-peer" not in uri
@pytest.mark.parametrize("save_to_store", [False, True])
@pytest.mark.parametrize("credential", ["dev", "user", "root", "service"])
@pytest.mark.parametrize("stale_env", [False, True])
def test_new_setup_does_not_ask_for_or_save_peer(
tmp_path,
monkeypatch,
save_to_store,
credential,
stale_env,
):
from hermes_cli import memory_setup
home = tmp_path / "hermes"
home.mkdir()
(home / ".env").write_text(
"OPENVIKING_AGENT=old-peer\nOTHER_KEY=keep\n", encoding="utf-8"
)
config = {"memory": {"openviking": {"agent": "old-peer", "recall_limit": 9}}}
if stale_env:
for key in ov._OPENVIKING_ENV_KEYS:
monkeypatch.setenv(
key, "old-peer" if key == "OPENVIKING_AGENT" else "old-value"
)
monkeypatch.setenv("OTHER_KEY", "keep")
validations = []
def validate(values, **kwargs):
validations.append(dict(values))
role = (
"root"
if credential == "root"
else "user"
if values.get("api_key")
else None
)
return True, "", role
def prompt(label, default=None, secret=False):
values = {
"OpenViking server URL": "http://localhost:1933",
"OpenViking user API key": "test-user-key",
"OpenViking root API key": "test-root-key",
"OpenViking API key": "test-service-key",
"OpenViking account": "account",
"OpenViking user": "alice",
"OpenViking profile name": "personal",
}
assert label in values, f"Unexpected setup question: {label}"
return values[label]
def select(title, options, **kwargs):
choices = {
" OpenViking connection": 0 if credential == "service" else 1,
" OpenViking credential": {"dev": 2, "user": 0, "root": 1}.get(
credential, 0
),
" Save OpenViking config": int(save_to_store),
}
assert title in choices, f"Unexpected setup menu: {title}"
return choices[title]
monkeypatch.setattr(memory_setup, "_prompt", prompt)
monkeypatch.setattr(memory_setup, "_curses_select", select)
monkeypatch.setattr(ov, "_validate_openviking_reachability", lambda *a: (True, ""))
monkeypatch.setattr(ov, "_validate_openviking_setup_values", validate)
ov.OpenVikingMemoryProvider().post_setup(str(home), config)
assert validations
assert all(values["agent"] == "" for values in validations)
assert "OPENVIKING_AGENT" not in (home / ".env").read_text(encoding="utf-8")
assert "OTHER_KEY=keep" in (home / ".env").read_text(encoding="utf-8")
saved_config = ov._load_hermes_openviking_config()
assert saved_config["recall_limit"] == 9
settings = ov._resolve_connection_settings(saved_config)
assert settings["agent"] == ""
assert settings == {
key: validations[-1][key]
for key in ("endpoint", "api_key", "account", "user", "agent")
}
assert "OPENVIKING_AGENT" not in os.environ
assert os.environ["OTHER_KEY"] == "keep"
if save_to_store:
saved = json.loads(
Path(saved_config["ovcli_config_path"]).read_text(encoding="utf-8")
)
assert "actor_peer_id" not in saved
assert "agent_id" not in saved
@pytest.mark.parametrize("peer", ["", "work-assistant"])
def test_hermes_only_save_uses_the_same_clean_values_in_file_and_process(
tmp_path, peer
):
from dotenv import dotenv_values
env_path = tmp_path / ".env"
ov._save_hermes_only_config(
config={"memory": {}},
provider_config={},
env_path=env_path,
values={
"endpoint": "http://localhost:29333",
"api_key": "test\r\n-key\x00",
"agent": peer,
},
)
expected = {
"OPENVIKING_ENDPOINT": "http://localhost:29333",
"OPENVIKING_API_KEY": "test-key",
}
if peer:
expected["OPENVIKING_AGENT"] = peer
assert dict(dotenv_values(env_path)) == expected
assert {
key: os.environ[key] for key in ov._OPENVIKING_ENV_KEYS if key in os.environ
} == expected
def test_hermes_only_save_failure_leaves_process_environment_unchanged(
tmp_path, monkeypatch
):
for key in ov._OPENVIKING_ENV_KEYS:
monkeypatch.setenv(key, "old-value")
def fail_write(*args, **kwargs):
raise OSError("test write failure")
monkeypatch.setattr(ov, "_write_env_vars", fail_write)
with pytest.raises(OSError, match="test write failure"):
ov._save_hermes_only_config(
config={"memory": {}},
provider_config={},
env_path=tmp_path / ".env",
values={"endpoint": "http://localhost:29333", "api_key": "test-key"},
)
assert all(os.environ[key] == "old-value" for key in ov._OPENVIKING_ENV_KEYS)
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_wire_requests_keep_writes_and_session_messages_in_the_selected_scope(
tmp_path,
monkeypatch,
peer,
):
records = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def respond(self, payload):
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self.respond({"status": "ok", "healthy": True, "version": "test"})
elif self.path == "/api/v1/system/status":
self.respond({"result": {"user": "alice"}})
else:
self.send_error(404)
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
records.append((self.path, dict(self.headers), payload))
self.respond({"status": "ok", "result": {"written_bytes": 10}})
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
home = tmp_path / "hermes"
home.mkdir()
provider_config = {"endpoint": f"http://127.0.0.1:{server.server_port}"}
if peer:
provider_config["agent"] = peer
(home / "config.yaml").write_text(
yaml.safe_dump({
"memory": {"provider": "openviking", "openviking": provider_config}
}),
encoding="utf-8",
)
provider = ov.OpenVikingMemoryProvider()
try:
provider.initialize("peer-test", hermes_home=str(home))
assert provider._client is not None
result = json.loads(
provider.handle_tool_call("viking_remember", {"content": "I like tea"})
)
assert result["status"] == "submitted"
provider.on_memory_write("add", "user", "I like coffee")
provider.sync_turn("hello", "hi", session_id="peer-test")
assert provider._drain_writers("peer-test", timeout=5.0)
provider.sync_turn(
"next",
"reply",
session_id="peer-test",
messages=[
{"role": "user", "content": "next"},
{"role": "assistant", "content": "reply"},
],
)
assert provider._drain_writers("peer-test", timeout=5.0)
provider.on_session_end([])
finally:
provider.shutdown()
server.shutdown()
server.server_close()
thread.join(timeout=3.0)
assert records
for _path, headers, _payload in records:
if peer:
assert headers["X-OpenViking-Actor-Peer"] == peer
else:
assert "X-OpenViking-Actor-Peer" not in headers
writes = [
payload for path, _, payload in records if path == "/api/v1/content/write"
]
prefix = f"peers/{peer}/" if peer else ""
assert {write["content"] for write in writes} == {"I like coffee"}
assert all(
write["uri"].startswith(f"viking://user/alice/{prefix}memories/")
for write in writes
)
remember_messages = [
(path, payload)
for path, _, payload in records
if path.startswith("/api/v1/sessions/hermes-remember-")
and path.endswith("/messages")
]
assert len(remember_messages) == 1
remember_path, remember_message = remember_messages[0]
remember_session = remember_path.removesuffix("/messages")
assert remember_message == {
"role": "user",
"parts": [{"type": "text", "text": "I like tea"}],
}
assert any(path == f"{remember_session}/commit" for path, _, _ in records)
batches = [
payload["messages"]
for path, _, payload in records
if path.endswith("/messages/batch")
]
assert len(batches) == 2
for batch in batches:
assert "peer_id" not in batch[0]
if peer:
assert batch[1]["peer_id"] == peer
else:
assert "peer_id" not in batch[1]
assert any(path.endswith("/commit") for path, _, _ in records)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
"""Tests for OpenViking memory-provider shutdown teardown.
The runtime-autostart waiter is a tracked ``daemon=True`` thread that blocks
on network health probes. If ``shutdown()`` doesn't join it (and the waiter
doesn't bail on the shutdown flag), it can be left alive at interpreter exit,
which crashes CPython with SIGABRT at ``Py_FinalizeEx``. These tests assert
the waiter short-circuits on shutdown and that ``shutdown()`` waits for the
runtime-start thread.
"""
from __future__ import annotations
import threading
import time
from unittest.mock import patch
import plugins.memory.openviking as openviking_module
from plugins.memory.openviking import OpenVikingMemoryProvider
def test_wait_for_health_short_circuits_on_should_stop():
"""The health waiter returns False without probing when should_stop is set,
so the daemon thread running it can be join()ed promptly at shutdown."""
probes: list[str] = []
def _reach(endpoint):
probes.append(endpoint)
return (False, "down")
with patch.object(
openviking_module, "_validate_openviking_reachability", _reach
):
result = openviking_module._wait_for_openviking_health(
"http://example.invalid",
timeout_seconds=60.0,
should_stop=lambda: True,
)
assert result is False
assert probes == [] # bailed before the first network probe
def test_shutdown_waits_for_runtime_start_thread():
"""shutdown() must join the runtime-autostart waiter thread.
The fake waiter does post-stop work (a short sleep) once it observes the
shutdown flag. If shutdown() joins it, that work has completed by the time
shutdown() returns; without the join, shutdown() returns early and the
thread is still running (the SIGABRT-at-exit failure mode).
"""
provider = OpenVikingMemoryProvider()
started = threading.Event()
finished = threading.Event()
def _runtime():
started.set()
while not provider._shutting_down:
time.sleep(0.01)
time.sleep(0.2) # work that must finish during shutdown's join
finished.set()
t = threading.Thread(target=_runtime, daemon=True, name="openviking-runtime-start")
provider._runtime_start_thread = t
t.start()
assert started.wait(2.0)
provider.shutdown()
assert finished.is_set()
assert not t.is_alive()
@@ -0,0 +1,241 @@
from __future__ import annotations
import sqlite3
from unittest.mock import MagicMock
import agent.file_safety as fs
import pytest
import plugins.memory.retaindb as retaindb
from plugins.memory.retaindb import RetainDBMemoryProvider
def test_write_queue_closes_owner_connection(tmp_path):
queue = retaindb._WriteQueue(object(), tmp_path / "retaindb.db")
owner_conn = queue._local.conn
worker = retaindb.threading.Thread(target=queue._get_conn)
worker.start()
worker.join()
queue.shutdown()
assert not queue._connections
with pytest.raises(sqlite3.ProgrammingError):
owner_conn.execute("SELECT 1")
def test_write_queue_ignores_enqueue_after_shutdown(tmp_path):
queue = retaindb._WriteQueue(object(), tmp_path / "retaindb.db")
queue.shutdown()
queue.enqueue("user", "session", [])
assert not queue._connections
def test_prefetch_does_not_spawn_when_previous_batch_is_alive(monkeypatch):
provider = RetainDBMemoryProvider()
provider._client = object()
class _RunningThread:
def join(self, timeout):
pass
def is_alive(self):
return True
previous = _RunningThread()
provider._prefetch_threads = [previous]
created = []
class _Thread:
def __init__(self, *args, **kwargs):
created.append((args, kwargs))
def start(self):
pass
monkeypatch.setattr(retaindb.threading, "Thread", _Thread)
provider.queue_prefetch("query")
assert provider._prefetch_threads == [previous]
assert not created
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"
def _capture_initialized_client(monkeypatch, tmp_path):
"""Patch _Client/_WriteQueue/get_hermes_home; return a dict capturing args."""
import hermes_constants
import plugins.memory.retaindb as retaindb_module
captured: dict = {}
class _FakeClient:
def __init__(self, api_key, base_url, project):
captured["api_key"] = api_key
captured["base_url"] = base_url
captured["project"] = project
self.project = project
monkeypatch.setattr(retaindb_module, "_Client", _FakeClient)
monkeypatch.setattr(retaindb_module, "_WriteQueue", lambda *a, **k: MagicMock())
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
return retaindb_module, captured
def test_retaindb_config_loader_uses_readonly_config(monkeypatch):
import hermes_cli.config as config_mod
import plugins.memory.retaindb as retaindb_module
backing_config = {
"memory": {
"retaindb": {
"base_url": "https://saved.example",
"project": "saved-project",
}
}
}
monkeypatch.setattr(config_mod, "load_config_readonly", lambda: backing_config)
monkeypatch.setattr(
config_mod,
"load_config",
MagicMock(side_effect=AssertionError("read-only provider path must not load a mutable copy")),
)
config = retaindb_module._load_retaindb_config()
assert config == backing_config["memory"]["retaindb"]
assert config is not backing_config["memory"]["retaindb"]
def test_initialize_reads_real_dashboard_config_file(tmp_path, monkeypatch):
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
(tmp_path / "config.yaml").write_text(
"""\
memory:
provider: retaindb
retaindb:
base_url: https://retaindb.saved.example/
project: dashboard-project
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == "https://retaindb.saved.example"
assert captured["project"] == "dashboard-project"
def test_initialize_reads_base_url_and_project_from_config_yaml(tmp_path, monkeypatch):
"""#68209: non-secret base_url/project come from config.yaml when env is unset."""
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(
retaindb_module,
"_load_retaindb_config",
lambda: {"base_url": "https://retaindb.example.com/", "project": "cfg-project"},
)
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == "https://retaindb.example.com" # trailing slash stripped
assert captured["project"] == "cfg-project"
def test_initialize_env_overrides_config_yaml(tmp_path, monkeypatch):
for var in ("RETAINDB_API_KEY", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("RETAINDB_BASE_URL", "https://env.example.com")
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(
retaindb_module,
"_load_retaindb_config",
lambda: {"base_url": "https://cfg.example.com", "project": "cfg-project"},
)
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == "https://env.example.com"
def test_initialize_combines_scoped_secret_with_dashboard_config(tmp_path, monkeypatch):
"""Rebase regression: scoped secrets and non-secret config must coexist."""
from agent.secret_scope import (
is_multiplex_active,
reset_secret_scope,
set_multiplex_active,
set_secret_scope,
)
monkeypatch.setenv("RETAINDB_API_KEY", "env-other-profile")
monkeypatch.delenv("RETAINDB_BASE_URL", raising=False)
monkeypatch.delenv("RETAINDB_PROJECT", raising=False)
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(
retaindb_module,
"_load_retaindb_config",
lambda: {"base_url": "https://dashboard.example.com/", "project": "dashboard-project"},
)
previous_multiplex_state = is_multiplex_active()
set_multiplex_active(True)
token = set_secret_scope({"RETAINDB_API_KEY": "scoped-key"})
try:
RetainDBMemoryProvider().initialize("sess-1")
finally:
reset_secret_scope(token)
set_multiplex_active(previous_multiplex_state)
assert captured == {
"api_key": "scoped-key",
"base_url": "https://dashboard.example.com",
"project": "dashboard-project",
}
def test_initialize_falls_back_to_default_base_url(tmp_path, monkeypatch):
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(retaindb_module, "_load_retaindb_config", lambda: {})
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == retaindb_module._DEFAULT_BASE_URL
assert captured["project"] == "default"
@@ -0,0 +1,458 @@
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",
base_url: str = ""):
self.api_key = api_key
self.timeout = timeout
self.container_tag = container_tag
self.search_mode = search_mode
self.base_url = base_url
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_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_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_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_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_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_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_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"
# -- Base URL tests -------------------------------------------------------------
def test_base_url_defaults_to_cloud(monkeypatch, tmp_path):
"""Without config or env override, the client targets api.supermemory.ai."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.delenv("SUPERMEMORY_BASE_URL", raising=False)
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._base_url == "https://api.supermemory.ai"
assert p._client.base_url == "https://api.supermemory.ai"
def test_client_passes_custom_base_url_to_sdk(monkeypatch):
"""SDK operations and raw conversation ingest share one normalized base URL."""
import sys
import types
from plugins.memory.supermemory import _SupermemoryClient
captured = {}
class StubSupermemory:
def __init__(self, **kwargs):
captured.update(kwargs)
module = types.ModuleType("supermemory")
module.Supermemory = StubSupermemory
monkeypatch.setitem(sys.modules, "supermemory", module)
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *args, **kwargs: None)
client = _SupermemoryClient(
api_key="test-key",
timeout=1.0,
container_tag="hermes",
base_url="http://localhost:6767/",
)
assert client._base_url == "http://localhost:6767"
assert captured["base_url"] == "http://localhost:6767"
@pytest.mark.parametrize(
("base_url", "expected_url"),
[
("https://api.supermemory.ai", "https://api.supermemory.ai/v4/conversations"),
("http://localhost:6767", "http://localhost:6767/v4/conversations"),
],
)
def test_ingest_conversation_uses_client_base_url(monkeypatch, base_url, expected_url):
"""Raw conversation ingest follows the same endpoint as SDK operations."""
from plugins.memory.supermemory import _SupermemoryClient
client = _SupermemoryClient.__new__(_SupermemoryClient)
client._api_key = "test-key"
client._container_tag = "hermes"
client._timeout = 1.0
client._base_url = base_url
captured = {}
class _FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
return _FakeResponse()
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}])
assert captured["url"] == expected_url
# -- 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_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_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_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,367 @@
"""Unit tests for the CommandCode provider profiles.
CommandCode registers two profiles:
``commandcode``
``api_mode=chat_completions`` OpenAI-compatible. Defaults to
``deepseek/deepseek-v4-pro``. 20+ models via a single base URL.
``commandcode-anthropic``
``api_mode=anthropic_messages`` Anthropic Messages API-compatible.
Defaults to ``claude-sonnet-4-6``. Requires Bearer auth recognition
in ``agent/anthropic_adapter.py``.
Both share ``COMMANDCODE_API_KEY`` and ``https://api.commandcode.ai/provider/v1``.
"""
from __future__ import annotations
import pytest
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def commandcode_profile():
"""Resolve the registered CommandCode (chat_completions) profile."""
import model_tools # noqa: F401 — triggers discovery
import providers
profile = providers.get_provider_profile("commandcode")
assert profile is not None, "commandcode provider profile must be registered"
return profile
@pytest.fixture
def commandcode_anthropic_profile():
"""Resolve the registered CommandCode Anthropic profile."""
import model_tools # noqa: F401 — triggers discovery
import providers
profile = providers.get_provider_profile("commandcode-anthropic")
assert profile is not None, "commandcode-anthropic profile must be registered"
return profile
# ── Chat Completions profile ──────────────────────────────────────────────────
class TestCommandCodeProfileIdentity:
"""Profile metadata matches the declared contract."""
def test_name(self, commandcode_profile):
assert commandcode_profile.name == "commandcode"
def test_api_mode(self, commandcode_profile):
assert commandcode_profile.api_mode == "chat_completions"
def test_aliases(self, commandcode_profile):
assert "commandcode-chat" in commandcode_profile.aliases
def test_env_vars(self, commandcode_profile):
assert "COMMANDCODE_API_KEY" in commandcode_profile.env_vars
def test_base_url(self, commandcode_profile):
assert commandcode_profile.base_url == "https://api.commandcode.ai/provider/v1"
def test_display_name(self, commandcode_profile):
assert "CommandCode" in commandcode_profile.display_name
def test_has_fallback_models(self, commandcode_profile):
assert len(commandcode_profile.fallback_models) >= 5
# Should include the major families
names = " ".join(commandcode_profile.fallback_models)
assert "deepseek" in names
assert "Qwen" in names
assert "Kimi" in names
assert "gemini" in names
def test_default_aux_model(self, commandcode_profile):
assert commandcode_profile.default_aux_model == "deepseek/deepseek-v4-flash"
def test_signup_url(self, commandcode_profile):
assert "commandcode" in commandcode_profile.signup_url.lower()
def test_hostname_derived_from_base_url(self, commandcode_profile):
assert commandcode_profile.get_hostname() == "api.commandcode.ai"
class TestCommandCodeProfileNoThinkingInterference:
"""Chat completions profile is a no-op for thinking config — it delegates
to the underlying model's provider (DeepSeek, Qwen, etc.) for wire format.
"""
def test_passthrough_no_reasoning_config(self, commandcode_profile):
extra_body, top_level = commandcode_profile.build_api_kwargs_extras(
reasoning_config=None, model="deepseek/deepseek-v4-pro"
)
# Chat completions profile doesn't inject thinking params — that's
# the DeepSeek provider's job when routed through DeepSeek's own profile.
# When routed through CommandCode, the underlying model API handles it.
assert isinstance(extra_body, dict)
assert isinstance(top_level, dict)
# Default ProviderProfile returns ({}, {}).
def test_passthrough_with_reasoning_config(self, commandcode_profile):
extra_body, top_level = commandcode_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="deepseek/deepseek-v4-pro",
)
assert isinstance(extra_body, dict)
assert isinstance(top_level, dict)
# ── Anthropic Messages profile ────────────────────────────────────────────────
class TestCommandCodeAnthropicProfileIdentity:
"""Anthropic-compatible profile metadata."""
def test_name(self, commandcode_anthropic_profile):
assert commandcode_anthropic_profile.name == "commandcode-anthropic"
def test_api_mode(self, commandcode_anthropic_profile):
assert commandcode_anthropic_profile.api_mode == "anthropic_messages"
def test_aliases(self, commandcode_anthropic_profile):
assert "commandcode-claude" in commandcode_anthropic_profile.aliases
def test_env_vars(self, commandcode_anthropic_profile):
assert "COMMANDCODE_API_KEY" in commandcode_anthropic_profile.env_vars
def test_base_url(self, commandcode_anthropic_profile):
assert commandcode_anthropic_profile.base_url == "https://api.commandcode.ai/provider/v1"
def test_fallback_models_are_claude_family(self, commandcode_anthropic_profile):
for model in commandcode_anthropic_profile.fallback_models:
assert model.startswith("claude-"), (
f"All anthropic fallback models should be claude-*: got {model}"
)
def test_default_aux_model(self, commandcode_anthropic_profile):
assert commandcode_anthropic_profile.default_aux_model == "claude-haiku-4-5-20251001"
def test_display_name_distinct_from_chat(self, commandcode_anthropic_profile):
# The Anthropic profile should be distinguishable in /model picker
assert "(Anthropic)" in commandcode_anthropic_profile.display_name
def test_hostname_derived_from_base_url(self, commandcode_anthropic_profile):
assert commandcode_anthropic_profile.get_hostname() == "api.commandcode.ai"
# ── Bearer Auth Recognition ───────────────────────────────────────────────────
class TestCommandCodeAnthropicBearerAuth:
"""``agent/anthropic_adapter.py`` must recognize CommandCode as a
Bearer-auth endpoint, or the chat_completions transport falls back to
``x-api-key`` and gets a 401.
"""
def test_requires_bearer_auth_recognizes_commandcode(self):
from agent.anthropic_adapter import _requires_bearer_auth
assert _requires_bearer_auth("https://api.commandcode.ai/provider/v1") is True
assert _requires_bearer_auth("https://api.commandcode.ai/provider/v1/models") is True
assert _requires_bearer_auth("https://api.commandcode.ai/anthropic") is True
def test_bearer_auth_does_not_affect_unrelated(self):
from agent.anthropic_adapter import _requires_bearer_auth
# Native Anthropic still uses x-api-key
assert _requires_bearer_auth("https://api.anthropic.com") is False
# OpenRouter still uses Bearer through its own transport path
assert _requires_bearer_auth("https://openrouter.ai/api/v1") is False
def test_bearer_auth_case_insensitive(self):
from agent.anthropic_adapter import _requires_bearer_auth
assert _requires_bearer_auth("https://API.COMMANDCODE.AI/provider/v1") is True
# ── Registry integrity ───────────────────────────────────────────────────────
class TestCommandCodeRegistryIntegrity:
"""Both profiles are discoverable and distinct."""
def test_both_profiles_registered(self):
import model_tools # noqa: F401
import providers
chat = providers.get_provider_profile("commandcode")
anth = providers.get_provider_profile("commandcode-anthropic")
assert chat is not None
assert anth is not None
assert chat is not anth # distinct profile instances
def test_alias_lookup(self):
import model_tools # noqa: F401
import providers
assert providers.get_provider_profile("commandcode-chat") is not None
assert providers.get_provider_profile("commandcode-claude") is not None
def test_unknown_returns_none(self):
import model_tools # noqa: F401
import providers
assert providers.get_provider_profile("commandcode-nonexistent") is None
# ── Model list filtering ──────────────────────────────────────────────────────
class TestCommandCodeModelFiltering:
"""``fetch_models`` filtering contracts."""
def test_anthropic_profile_filters_to_claude(self):
"""If we mock a response with mixed models, anthropic profile
should only return claude-* models.
"""
from plugins.model_providers.commandcode import CommandCodeAnthropicProfile
profile = CommandCodeAnthropicProfile(
name="test-cc-anth",
api_mode="anthropic_messages",
env_vars=("COMMANDCODE_API_KEY",),
base_url="https://api.commandcode.ai/provider/v1",
)
# Don't actually hit the network — just test the filter logic.
# The class has a fetch_models override that filters.
# We verify the filter works by inspecting the method.
import inspect
source = inspect.getsource(profile.fetch_models)
assert "startswith(\"claude-\")" in source or '"claude-" in m' in source, (
"CommandCodeAnthropicProfile.fetch_models should filter to claude-* models"
)
# ── Picker contract ──────────────────────────────────────────────────────────
class TestCommandCodeFetchModelsPickerContract:
"""``fetch_models`` must accept the kwargs the model picker passes.
Regression: the generic live-fetch path in ``hermes_cli/models.py``
(``provider_model_ids``) calls ``profile.fetch_models(api_key=...,
base_url=...)``. The original CommandCode overrides only accepted
``api_key``/``timeout``, so every picker open raised TypeError, which
was swallowed, leaving the provider with zero models.
"""
@pytest.mark.parametrize("profile_name", ["commandcode", "commandcode-anthropic"])
def test_accepts_base_url_kwarg(self, profile_name):
import inspect
import model_tools # noqa: F401 — triggers discovery
import providers
profile = providers.get_provider_profile(profile_name)
assert profile is not None
assert "base_url" in inspect.signature(profile.fetch_models).parameters
def test_resolve_provider_full(self):
"""Both profiles must resolve through the model-switch path.
Regression: ``resolve_provider_full`` only knew models.dev + overlay
providers, so plugin-only providers (commandcode) failed with
"Unknown provider" on /model switches even though the picker listed
them.
"""
from hermes_cli.providers import resolve_provider_full
chat = resolve_provider_full("commandcode", {}, [])
assert chat is not None and chat.id == "commandcode"
assert chat.transport == "openai_chat"
assert "COMMANDCODE_API_KEY" in chat.api_key_env_vars
anth = resolve_provider_full("commandcode-anthropic", {}, [])
assert anth is not None and anth.id == "commandcode-anthropic"
assert anth.transport == "anthropic_messages"
# ── base_url endpoint override ───────────────────────────────────────────────
class TestCommandCodeBaseUrlOverride:
"""A custom base_url must redirect the catalog fetch; the default must not.
The picker passes ``base_url`` unconditionally (profile default when the
user configured nothing), so only a value differing from the default
``_COMMANDCODE_BASE`` counts as a customised endpoint.
"""
def _serve(self, models):
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"data": models}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass
server = HTTPServer(("127.0.0.1", 0), H)
Thread(target=server.serve_forever, daemon=True).start()
return server, server.server_address[1]
def test_custom_base_url_redirects_fetch(self, commandcode_profile):
server, port = self._serve([{"id": "proxied/model-x"}])
try:
result = commandcode_profile.fetch_models(
api_key="k", base_url=f"http://127.0.0.1:{port}"
)
assert result == ["proxied/model-x"]
finally:
server.shutdown()
def test_custom_base_url_redirects_anthropic_fetch(
self, commandcode_anthropic_profile
):
server, port = self._serve(
[{"id": "claude-sonnet-4-6"}, {"id": "deepseek/deepseek-v4-pro"}]
)
try:
result = commandcode_anthropic_profile.fetch_models(
api_key="k", base_url=f"http://127.0.0.1:{port}"
)
assert result == ["claude-sonnet-4-6"] # claude-* filter still applies
finally:
server.shutdown()
def test_default_base_url_hits_default_endpoint(self, commandcode_profile):
"""Echoing the profile default back must NOT count as an override."""
import sys
from unittest.mock import patch as mock_patch
# The bundled plugin module is registered at discovery time under
# ``plugins.model_providers.commandcode`` — resolve via the profile's
# own __module__ so the test doesn't depend on discovery mechanics.
cc_mod = sys.modules[type(commandcode_profile).__module__]
captured = {}
class _FakeResp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return b'{"data": [{"id": "m1"}]}'
def fake_urlopen(req, timeout=0):
captured["url"] = req.full_url
return _FakeResp()
with mock_patch.object(
cc_mod.urllib.request, "urlopen", side_effect=fake_urlopen
):
result = commandcode_profile.fetch_models(
api_key="k", base_url=cc_mod._COMMANDCODE_BASE + "/"
)
assert result == ["m1"]
assert captured["url"] == cc_mod._COMMANDCODE_MODELS_URL
@@ -0,0 +1,102 @@
"""Unit tests for the Copilot provider profile's reasoning-effort wiring.
GitHub Copilot serves different models with different supported reasoning-effort
sets (the live ``/models`` catalog reports them per model). The profile must
forward the requested effort when the catalog lists it as supported, and only
downgrade to the nearest weaker supported level when it does not, rather than
unconditionally collapsing ``xhigh`` to ``high`` (which silently capped models
that actually support the higher level).
These tests pin that contract without going live, by stubbing the catalog
lookup ``github_model_reasoning_efforts``.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def copilot_profile():
"""Resolve the registered Copilot profile.
Importing ``model_tools`` triggers plugin discovery, which registers the
Copilot 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("copilot")
assert profile is not None, "copilot provider profile must be registered"
return profile
def _patch_efforts(monkeypatch, efforts):
"""Stub the catalog lookup the profile calls for supported efforts."""
import hermes_cli.models as models_mod
monkeypatch.setattr(
models_mod, "github_model_reasoning_efforts", lambda model: list(efforts)
)
class TestCopilotReasoningEffortClamp:
def test_supported_effort_forwarded_verbatim(self, copilot_profile, monkeypatch):
"""xhigh is forwarded unchanged when the catalog lists it."""
_patch_efforts(monkeypatch, ["minimal", "low", "medium", "high", "xhigh"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="gpt-5.5",
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "xhigh"}
def test_xhigh_downgrades_to_high_when_unsupported(self, copilot_profile, monkeypatch):
"""A model whose catalog lacks xhigh gets the nearest weaker level."""
_patch_efforts(monkeypatch, ["low", "medium", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="o-series-model",
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "high"}
def test_minimal_downgrades_to_low_when_unsupported(self, copilot_profile, monkeypatch):
_patch_efforts(monkeypatch, ["low", "medium", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="o-series-model",
reasoning_config={"effort": "minimal"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "low"}
def test_unsupported_effort_falls_back_to_medium(self, copilot_profile, monkeypatch):
"""An effort not in the set, with no specific rule, falls to medium."""
_patch_efforts(monkeypatch, ["low", "medium", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="some-model",
reasoning_config={"effort": "garbage"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "medium"}
def test_falls_back_to_first_supported_when_no_medium(self, copilot_profile, monkeypatch):
"""If medium isn't supported either, pick the first supported level."""
_patch_efforts(monkeypatch, ["low", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="weird-model",
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
)
# xhigh not supported, high IS supported → high wins via the xhigh rule.
assert extra_body["reasoning"] == {"effort": "high"}
def test_first_supported_when_no_rule_matches(self, copilot_profile, monkeypatch):
_patch_efforts(monkeypatch, ["low", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="weird-model",
reasoning_config={"effort": "garbage"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "low"}
@@ -0,0 +1,182 @@
"""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 on Ollama extra_body.think = False + reasoning_effort=none
- disabled elsewhere reasoning_effort=none, no think (strict APIs 422)
- 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 on an Ollama URL → reasoning_effort='none' + think=False.
Both fields are required on Ollama: /v1/chat/completions silently
ignores extra_body.think (only /api/chat honours it ollama#14820)
but respects top-level reasoning_effort (#25758). think=False stays
for proxies and the native /api/chat path.
"""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
model="qwen3",
base_url="http://127.0.0.1:11434/v1",
)
assert eb == {"think": False}
assert tl == {"reasoning_effort": "none"}
def test_effort_none_sends_think_false(self, custom_profile):
"""effort='none' is the disable alias → same dual emission on Ollama."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "none"},
model="qwen3",
base_url="http://localhost:11434/v1",
)
assert eb == {"think": False}
assert tl == {"reasoning_effort": "none"}
def test_disabled_omits_think_on_mistral(self, custom_profile):
"""Strict OpenAI-compat hosts forbid extra ``think`` (HTTP 422)."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "none"},
model="mistral-small-latest",
base_url="https://api.mistral.ai/v1",
)
assert "think" not in eb
assert tl == {"reasoning_effort": "none"}
def test_disabled_omits_think_without_base_url(self, custom_profile):
"""Unknown custom endpoint — do not send the Ollama-only flag."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="glm-5.2"
)
assert "think" not in eb
assert tl == {"reasoning_effort": "none"}
@pytest.mark.parametrize(
"base_url",
[
"http://127.0.0.1:8080/v1",
"http://localhost:1234/v1",
"https://api.groq.com/openai/v1",
],
)
def test_disabled_omits_think_on_non_ollama_relays(self, custom_profile, base_url):
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"effort": "none"},
model="llama3",
base_url=base_url,
)
assert "think" not in eb
assert tl == {"reasoning_effort": "none"}
def test_disabled_sends_think_false_on_ollama_cloud_host(self, custom_profile):
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
model="qwen3",
base_url="https://ollama.com/v1",
)
assert eb == {"think": False}
assert tl == {"reasoning_effort": "none"}
@pytest.mark.parametrize(
"base_url",
[
"http://myhost:99999/v1", # out-of-range port: OpenAI client accepts it
"http://localhost:80a/v1", # non-integer port
"http://localhost:11434./v1", # trailing-dot port
],
)
def test_malformed_port_does_not_raise(self, custom_profile, base_url):
"""Malformed ports must not raise — urlparse's ``port`` is ValueError-happy.
The OpenAI client accepts ``http://myhost:99999/v1`` at construction
(only httpx fails later), so these URLs reach ``build_api_kwargs_extras``
in production. The heuristic must treat them as non-Ollama rather than
killing the kwargs build.
"""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
model="qwen3",
base_url=base_url,
)
assert "think" not in eb
assert tl == {"reasoning_effort": "none"}
@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_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 == {}
@@ -0,0 +1,201 @@
"""Unit tests for the DeepSeek provider profile's thinking-mode wiring.
DeepSeek V4 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 == {}
@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 gets thinking; V3 / unknown stay untouched."""
@pytest.mark.parametrize(
"model",
[
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-future-variant",
"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-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_full_kwargs_omit_thinking(self, deepseek_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="deepseek-v3-0324",
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_v4_flash(self, deepseek_profile):
assert deepseek_profile.default_aux_model == "deepseek-v4-flash"
def test_fallback_models_are_v4_only(self, deepseek_profile):
assert deepseek_profile.fallback_models == (
"deepseek-v4-pro",
"deepseek-v4-flash",
)
def test_consumer_api_returns_deepseek_v4_flash(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") == "deepseek-v4-flash"
@@ -0,0 +1,94 @@
"""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_attribution_matches_canonical_hermes_values(self, fireworks_profile):
"""Fireworks requests carry the same attribution identity Hermes sends
everywhere else.
Asserted against the shared constant rather than the literals so a
rebrand can't leave one provider on a stale referer/title.
"""
from agent.auxiliary_client import _OR_HEADERS_BASE
headers = fireworks_profile.default_headers
assert headers["HTTP-Referer"] == _OR_HEADERS_BASE["HTTP-Referer"]
assert headers["X-Title"] == _OR_HEADERS_BASE["X-Title"]
def test_user_agent_identifies_hermes(self, fireworks_profile):
# Prefix, not the full string — the version moves every release.
assert fireworks_profile.default_headers["User-Agent"].startswith("HermesAgent/")
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,27 @@
"""Contract tests for the native Google Gemini provider profile."""
from __future__ import annotations
import pytest
@pytest.fixture
def gemini_profile():
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("gemini")
assert profile is not None, "gemini provider profile must be registered"
return profile
def test_native_gemini_auxiliary_default_is_in_curated_catalog(gemini_profile):
"""The profile's default_aux_model must stay in lockstep with the curated
model picker catalog whatever model the default points at has to be
one the picker can actually offer. Deliberately durable against future
model-generation bumps: it does not pin either side to a frozen
model-name string, only to the invariant that they never drift apart.
"""
from hermes_cli.models import _PROVIDER_MODELS
assert gemini_profile.default_aux_model in _PROVIDER_MODELS["gemini"]
@@ -0,0 +1,148 @@
"""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,expected",
[
("low", "low"),
("minimal", "low"),
("medium", "high"),
("high", "high"),
("xhigh", "max"),
("max", "max"),
("ultra", "max"),
],
)
def test_effort_mapped_to_k3_vocabulary(self, kimi_profile, effort, expected):
"""Hermes' wider effort vocabulary is mapped onto K3's low/high/max."""
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}
)
assert top_level == {"reasoning_effort": expected}
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"])
def test_unrecognized_effort_falls_back_to_thinking(self, kimi_profile, effort):
"""Unknown efforts 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 == {}
@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 TestKimiModelDiscovery:
def test_malformed_base_url_is_unconfirmed_and_filters_k3(self, kimi_profile):
"""Malformed user URLs must fall through safely, never authorize K3."""
from unittest.mock import patch
from providers.base import ProviderProfile
with patch.object(
ProviderProfile,
"fetch_models",
return_value=["k3", "kimi-k2.6"],
):
models = kimi_profile.fetch_models(
api_key="test-key",
base_url="https://[api.kimi.com/coding",
)
assert models == ["kimi-k2.6"]
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",
)
@@ -0,0 +1,183 @@
"""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 == {}
@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,139 @@
"""Unit tests for the Nous Portal profile's reasoning wiring.
The Portal honors ``reasoning: {enabled: false}`` it is the only wire shape
that does, and ``extra_body.thinking`` is not forwarded upstream. The profile
used to drop a disable for every model, which on a thinking-first route like
``deepseek/deepseek-v4-pro`` (catalog: ``default_effort: high``) meant the
upstream default applied and "thinking off" burned reasoning tokens anyway.
A disable is still dropped for reasoning-mandatory routes, which answer
``reasoning: {enabled: false}`` with HTTP 400, and for models the catalog
can't speak to — an unknown model errs toward the old behavior rather than
risking a 400 on a cold first turn.
These tests pin that contract without going live.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def nous_profile():
"""Resolve the registered Nous profile through the real discovery path."""
# ``model_tools`` triggers plugin discovery on import, which is what
# registers the Nous profile in the global provider registry.
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("nous")
assert profile is not None, "nous provider profile must be registered"
return profile
@pytest.fixture
def portal_catalog(monkeypatch):
"""Prime the Portal reasoning-capability cache with known entries."""
import hermes_cli.models as models_mod
monkeypatch.setattr(models_mod, "_nous_reasoning_caps_failed_at", None)
monkeypatch.setattr(models_mod, "_nous_reasoning_caps_cache", {
"deepseek/deepseek-v4-pro": {
"supports_reasoning": True,
"supported_efforts": ["xhigh", "high"],
"mandatory": False,
},
"arcee-ai/trinity-large-thinking": {
"supports_reasoning": True,
"supported_efforts": None,
"mandatory": True,
},
# Catalogued, and it takes no reasoning parameter at all.
"moonshotai/kimi-k3-instruct": {"supports_reasoning": False},
})
class TestNousReasoningWireShape:
"""``build_api_kwargs_extras`` produces the Portal's wire format."""
def test_disable_reaches_optional_reasoning_model(self, nous_profile, portal_catalog):
"""The knob the user set is the knob the Portal receives."""
extra_body, top_level = nous_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=True,
model="deepseek/deepseek-v4-pro",
)
assert extra_body == {"reasoning": {"enabled": False}}
assert top_level == {}
def test_disable_dropped_for_mandatory_reasoning_model(self, nous_profile, portal_catalog):
"""Mandatory routes 400 on a disable — send nothing instead."""
extra_body, _ = nous_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=True,
model="arcee-ai/trinity-large-thinking",
)
assert "reasoning" not in extra_body
def test_disable_dropped_for_unknown_model(self, nous_profile, portal_catalog):
"""Unlisted / cold catalog → fail safe, never risk the 400."""
extra_body, _ = nous_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=True,
model="private/unlisted-route",
)
assert "reasoning" not in extra_body
def test_disable_dropped_for_non_reasoning_route(self, nous_profile, portal_catalog):
"""A route the catalog says takes no reasoning parameter gets none.
Hermes' own ``supports_reasoning`` can disagree with the Portal about a
given route; when it does, the catalog of the service actually serving
the model wins, and we don't send it a parameter it doesn't accept.
"""
extra_body, _ = nous_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=True,
model="moonshotai/kimi-k3-instruct",
)
assert "reasoning" not in extra_body
@pytest.mark.parametrize(
"model",
["deepseek/deepseek-v4-pro", "arcee-ai/trinity-large-thinking", "private/unlisted-route"],
)
def test_enabled_config_always_forwarded(self, nous_profile, portal_catalog, model):
"""Mandatory-ness only gates the disable; an enable always ships."""
extra_body, _ = nous_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model=model,
)
assert extra_body["reasoning"] == {"enabled": True, "effort": "high"}
def test_no_config_defaults_to_medium(self, nous_profile, portal_catalog):
extra_body, _ = nous_profile.build_api_kwargs_extras(
reasoning_config=None,
supports_reasoning=True,
model="deepseek/deepseek-v4-pro",
)
assert extra_body["reasoning"] == {"enabled": True, "effort": "medium"}
def test_nothing_emitted_without_reasoning_support(self, nous_profile, portal_catalog):
extra_body, top_level = nous_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=False,
model="deepseek/deepseek-v4-pro",
)
assert extra_body == {}
assert top_level == {}
def test_caller_config_not_mutated(self, nous_profile, portal_catalog):
cfg = {"enabled": False}
nous_profile.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model="deepseek/deepseek-v4-pro",
)
assert cfg == {"enabled": False}
@@ -0,0 +1,234 @@
"""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(
supports_reasoning=True,
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(
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": effort},
)
assert top_level == {"reasoning_effort": effort}
# ── disabled → reasoning_effort:"none" (the only working off switch) ──
def test_explicitly_disabled_sends_none(self, ollama_cloud_profile):
"""Ollama Cloud defaults to thinking ON and ignores extra_body.thinking,
so disabling requires top-level reasoning_effort:"none" (verified live);
omitting the field would leave thinking on."""
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
supports_reasoning=True,
reasoning_config={"enabled": False},
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "none"}
def test_disabled_ignores_effort_field(self, ollama_cloud_profile):
"""Effort is overridden by the disable off switch when thinking is off."""
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
supports_reasoning=True,
reasoning_config={"enabled": False, "effort": "high"},
)
assert top_level == {"reasoning_effort": "none"}
# ── none effort → reasoning_effort:"none" ──────────────────────
def test_none_effort_sends_none(self, ollama_cloud_profile):
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": "none"},
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "none"}
# ── 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(
supports_reasoning=True,
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(
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": ""},
)
assert top_level == {}
# ── unknown / minimal effort → omitted (server default) ────────
def test_unknown_effort_omitted(self, ollama_cloud_profile):
"""Unrecognized effort is omitted, not forwarded verbatim, so the
model applies its own default. Matches the sibling deepseek profile,
which targets the same backend."""
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": "future-tier"},
)
assert top_level == {}
def test_minimal_effort_clamps_to_low(self, ollama_cloud_profile):
"""``minimal`` is a real Hermes effort level but is rejected by
Ollama Cloud's /v1/chat/completions. The shared clamp degrades it to
``low`` the nearest supported level instead of silently dropping
the user's ask (old behavior left the server default, i.e. MORE
thinking than requested: a ladder inversion)."""
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": "minimal"},
)
assert top_level == {"reasoning_effort": "low"}
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",
supports_reasoning=True,
)
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", {})
class TestOllamaCloudCapabilityGating:
"""reasoning_effort is gated on the model's thinking capability."""
def test_non_thinking_model_emits_nothing(self, ollama_cloud_profile):
"""A model that doesn't support thinking (supports_reasoning=False)
gets no reasoning_effort, even when an effort is requested Ollama
resolves thinking capability from /api/show, and we don't send a
meaningless field to e.g. gemma3 / qwen3-coder."""
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "xhigh"},
supports_reasoning=False,
)
assert extra_body == {}
assert top_level == {}
class TestOllamaModelSupportsThinking:
"""The /api/show capability probe used to resolve supports_reasoning."""
def _patch_show(self, monkeypatch, *, status=200, capabilities=None, raise_exc=None):
import httpx
class _Resp:
status_code = status
def json(self):
return {"capabilities": capabilities} if capabilities is not None else {}
class _Client:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, *a, **k):
if raise_exc:
raise raise_exc
return _Resp()
monkeypatch.setattr(httpx, "Client", _Client)
def test_thinking_capability_true(self, monkeypatch):
from hermes_cli.models import ollama_model_supports_thinking
self._patch_show(monkeypatch, capabilities=["completion", "tools", "thinking"])
assert (
ollama_model_supports_thinking(
"deepseek-v4-pro", "https://ollama.com/v1", "key"
)
is True
)
def test_probe_failure_returns_none(self, monkeypatch):
from hermes_cli.models import ollama_model_supports_thinking
self._patch_show(monkeypatch, status=404)
assert (
ollama_model_supports_thinking("x", "https://ollama.com/v1", "key") is None
)
def test_exception_returns_none(self, monkeypatch):
from hermes_cli.models import ollama_model_supports_thinking
self._patch_show(monkeypatch, raise_exc=RuntimeError("boom"))
assert (
ollama_model_supports_thinking("x", "https://ollama.com/v1", "key") is None
)
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,255 @@
"""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
@pytest.fixture
def opencode_zen_profile():
"""Resolve the registered OpenCode Zen provider profile."""
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("opencode-zen")
assert profile is not None, "opencode-zen provider profile must be registered"
return profile
class TestOpenCodeZenOxReasoning:
"""Ox Alpha Free uses OpenCode Zen's native reasoning_effort control."""
def test_max_effort_is_emitted(self, opencode_zen_profile):
extra_body, top_level = opencode_zen_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "max"},
model="x-preview-f-free",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "max"}
@pytest.mark.parametrize("reasoning_config", [None, {"enabled": False}])
def test_unset_or_disabled_preserves_server_default(
self, opencode_zen_profile, reasoning_config
):
extra_body, top_level = opencode_zen_profile.build_api_kwargs_extras(
reasoning_config=reasoning_config,
model="x-preview-f-free",
)
assert extra_body == {}
assert top_level == {}
def test_other_zen_models_are_untouched(self, opencode_zen_profile):
extra_body, top_level = opencode_zen_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "max"},
model="gemini-3-flash",
)
assert extra_body == {}
assert top_level == {}
def test_max_reaches_chat_completions_request(self, opencode_zen_profile):
from agent.transports.chat_completions import ChatCompletionsTransport
kwargs = ChatCompletionsTransport().build_kwargs(
model="x-preview-f-free",
messages=[{"role": "user", "content": "ping"}],
tools=None,
provider_profile=opencode_zen_profile,
reasoning_config={"enabled": True, "effort": "max"},
base_url="https://opencode.ai/zen/v1",
)
assert "extra_body" not in kwargs
assert kwargs["reasoning_effort"] == "max"
def test_unsupported_efforts_clamp_to_wire_vocabulary(self, opencode_zen_profile):
"""medium/xhigh are not on Ox Alpha's wire (400 raw); they must clamp
to the nearest supported level, never pass through."""
for requested, expected in (("medium", "low"), ("xhigh", "max")):
_, top_level = opencode_zen_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": requested},
model="x-preview-f-free",
)
assert top_level == {"reasoning_effort": expected}, requested
def test_opencode_free_profile_shares_the_translation(self):
"""Ox Alpha is reachable via the keyless opencode-free provider too;
its profile must emit the identical clamped reasoning_effort."""
import model_tools # noqa: F401
import providers
from providers.base import ProviderProfile
profile = providers.get_provider_profile("opencode-free")
assert profile is not None
assert (
type(profile).build_api_kwargs_extras
is not ProviderProfile.build_api_kwargs_extras
), "opencode-free must override build_api_kwargs_extras (aux gate)"
_, top_level = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "medium"},
model="x-preview-f-free",
)
assert top_level == {"reasoning_effort": "low"}
_, other = profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "max"},
model="big-pickle",
)
assert other == {}
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_clamps_to_low(self, opencode_go_profile):
# "minimal" is below Moonshot's floor — the shared clamp degrades it
# to "low" (nearest supported) instead of dropping the ask and
# leaving the server default (which was MORE thinking than asked).
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "minimal"},
model="kimi-k2.6",
)
assert extra_body == {}
assert top_level == {"reasoning_effort": "low"}
@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_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."""
@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,132 @@
"""Unit tests for the Upstage Solar provider profile.
Upstage Solar is a plain OpenAI-compatible api-key provider, so this verifies
the profile is registered correctly and wires the expected identity, endpoint,
auth, and catalog fields the contract every downstream layer (auth, models,
doctor, runtime_provider, transport) reads from.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def upstage_profile():
"""Resolve the registered Upstage profile via the provider registry.
Importing ``model_tools`` triggers plugin discovery, which registers the
Upstage profile. Going through ``get_provider_profile`` keeps the test
honest about the actual registration path (name + alias resolution).
"""
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("upstage")
assert profile is not None, "upstage provider profile must be registered"
return profile
class TestUpstageProfile:
def test_identity_and_endpoint(self, upstage_profile):
assert upstage_profile.name == "upstage"
assert upstage_profile.api_mode == "chat_completions"
assert upstage_profile.auth_type == "api_key"
assert upstage_profile.base_url == "https://api.upstage.ai/v1"
assert upstage_profile.get_hostname() == "api.upstage.ai"
def test_solar_alias_resolves(self):
import model_tools # noqa: F401
import providers
assert providers.get_provider_profile("solar") is upstage_profile_singleton()
def test_env_vars(self, upstage_profile):
# API key first, optional base-url override second (priority order).
assert upstage_profile.env_vars == ("UPSTAGE_API_KEY", "UPSTAGE_BASE_URL")
def test_fallback_models_are_agentic_pro_only(self, upstage_profile):
# Only the agentic, tool-calling Solar Pro models belong in the offline
# catalog — Mini is capable but not agentic, so it's never promoted as a
# default. Live /v1/models still surfaces everything when a key is set.
# Behavior contract (not a frozen list): non-empty, no denied families.
assert upstage_profile.fallback_models
for denied in ("solar-mini", "syn-pro"):
assert not any(
denied in m for m in upstage_profile.fallback_models
), f"non-agentic family {denied!r} must not be a fallback default"
def test_default_model_is_solar_pro3(self, upstage_profile):
# Entry [0] is the setup default (get_default_model_for_provider).
assert upstage_profile.fallback_models[0] == "solar-pro3"
def test_aux_model_left_empty(self, upstage_profile):
# Unset → auxiliary side tasks fall back to the user's main model.
assert upstage_profile.default_aux_model == ""
class TestUpstageReasoning:
"""``build_api_kwargs_extras`` wires Solar's top-level ``reasoning_effort``.
Solar Pro accepts ``reasoning_effort`` (minimal|low|medium|high, default
minimal=off) and never requires echoing ``reasoning_content`` back, so only
the request field is emitted always top-level, never in extra_body.
"""
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_pro_explicit_effort_passes_through(self, upstage_profile, effort):
extra_body, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}, model="solar-pro3"
)
assert extra_body == {}
assert top_level == {"reasoning_effort": effort}
def test_unknown_future_effort_collapses_to_high(self, upstage_profile):
# Guard against the #62650 recurrence: a future effort level Hermes
# adds above "high" must collapse to Solar's strongest, not silently
# downgrade to the medium default.
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "hyperthink"},
model="solar-pro3",
)
assert top_level == {"reasoning_effort": "high"}
def test_disabled_omits_field(self, upstage_profile):
# `/reasoning none` → enabled False → explicitly off.
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"}, model="solar-pro3"
)
assert top_level == {}
@pytest.mark.parametrize("model", ["solar-pro3", "solar-pro", "solar-open2"])
def test_no_config_defaults_reasoning_on(self, upstage_profile, model):
# Unset reasoning_config → default ON at medium (matches the /reasoning
# "medium (default)" label), not Solar's server default of minimal/off.
_, top_level = upstage_profile.build_api_kwargs_extras(model=model)
assert top_level == {"reasoning_effort": "medium"}
@pytest.mark.parametrize("model", ["solar-mini", "solar-mini-202610", "syn-pro"])
def test_deny_listed_models_never_send_reasoning(self, upstage_profile, model):
# solar-mini / syn-pro ignore reasoning_effort, so never send it —
# even when the user explicitly enables reasoning.
extra_body, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"}, model=model
)
assert extra_body == {}
assert top_level == {}
def test_none_model_defaults_to_reasoning(self, upstage_profile):
# No model in context → treated as reasoning-capable, consistent with
# the provider default (fallback_models[0] == "solar-pro3").
_, top_level = upstage_profile.build_api_kwargs_extras(model=None)
assert top_level == {"reasoning_effort": "medium"}
def upstage_profile_singleton():
import providers
return providers.get_provider_profile("upstage")
@@ -0,0 +1,224 @@
"""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 == {}
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 == {}
@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 TestZaiGLM53ReasoningEffort:
"""GLM-5.3's graded low/medium/high/max effort scale (issue #91789).
Verified live on api.z.ai/api/coding/paas/v4: all four levels accepted
with monotonic reasoning-token scaling. Unlike 5.2, low and medium must
reach the wire instead of clamping up to high.
"""
@pytest.mark.parametrize(
("effort", "expected"),
[
("low", "low"),
("medium", "medium"),
("high", "high"),
("max", "max"),
("xhigh", "max"),
("minimal", "low"),
],
)
def test_graded_efforts_pass_through(self, zai_profile, effort, expected):
extra_body, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
model="glm-5.3",
)
assert extra_body == {"thinking": {"type": "enabled"}}
assert top_level == {"reasoning_effort": expected}
@pytest.mark.parametrize(
"model",
["z-ai/glm-5.3", "glm-5-3", "glm-5p3", "zai-org-glm-5-3"],
)
def test_alias_spellings_get_graded_scale(self, zai_profile, model):
_, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "low"},
model=model,
)
assert top_level == {"reasoning_effort": "low"}
def test_glm_5_2_still_clamps_low_to_high(self, zai_profile):
"""The 5.3 widening must not leak into 5.2's two-level wire."""
_, top_level = zai_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "low"},
model="glm-5.2",
)
assert top_level == {"reasoning_effort": "high"}
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"}}
class TestZaiFullKwargsIntegration:
"""End-to-end: the transport's full kwargs carry the reasoning wiring."""
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"}
@@ -0,0 +1,125 @@
"""Regression tests for the Buzz requirement gate vs external secrets (#95216).
``check_requirements()`` runs at gateway startup BEFORE the per-profile
secret scope is installed, so a Bitwarden-managed ``BUZZ_PRIVATE_KEY`` (only
``BWS_ACCESS_TOKEN`` in ``.env``) was invisible to the bare env read and Buzz
was silently skipped. The fix adds a one-shot ``build_profile_secret_scope``
consultation to the unscoped fallback of ``_get_scoped_secret``.
The key values below are synthesized placeholders (never a usable secret).
"""
import os
import pytest
# Synthesized, non-credential placeholder (any non-empty string exercises
# the gate; no real key material is ever embedded here).
_STUB_KEY = os.environ.get("BUZZ_TEST_STUB_KEY") or ("k" * 8)
@pytest.fixture(autouse=True)
def _reset_unscoped_cache():
import plugins.platforms.buzz.adapter as adapter
prev = adapter._UNSCOPED_PROFILE_SECRETS
adapter._UNSCOPED_PROFILE_SECRETS = None
yield
adapter._UNSCOPED_PROFILE_SECRETS = prev
def _install_fake_scope(monkeypatch, secrets):
"""Point build_profile_secret_scope at a fake external-secret snapshot."""
import agent.secret_scope as secret_scope
calls = []
def fake_build(home): # noqa: ANN001 - test double
calls.append(home)
return dict(secrets)
monkeypatch.setattr(secret_scope, "build_profile_secret_scope", fake_build)
return calls
class TestRequirementGateSeesExternalSecrets:
def test_externally_managed_key_passes_gate(self, monkeypatch):
import plugins.platforms.buzz.adapter as adapter
monkeypatch.delenv("BUZZ_RELAY_URL", raising=False)
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "stub-token")
_install_fake_scope(
monkeypatch,
{"BUZZ_RELAY_URL": "wss://relay.example", "BUZZ_PRIVATE_KEY": _STUB_KEY},
)
assert adapter.check_requirements() is True
def test_gate_fails_cleanly_when_nothing_resolves(self, monkeypatch):
import plugins.platforms.buzz.adapter as adapter
monkeypatch.delenv("BUZZ_RELAY_URL", raising=False)
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
_install_fake_scope(monkeypatch, {})
assert adapter.check_requirements() is False
def test_relay_from_env_still_passes_with_external_key(self, monkeypatch):
import plugins.platforms.buzz.adapter as adapter
monkeypatch.setenv("BUZZ_RELAY_URL", "wss://relay.example")
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
_install_fake_scope(monkeypatch, {"BUZZ_PRIVATE_KEY": _STUB_KEY})
assert adapter.check_requirements() is True
def test_profile_scope_build_failure_degrades_to_not_configured(
self, monkeypatch
):
import agent.secret_scope as secret_scope
import plugins.platforms.buzz.adapter as adapter
monkeypatch.delenv("BUZZ_RELAY_URL", raising=False)
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
def boom(home): # noqa: ANN001 - test double
raise RuntimeError("external secret resolver unavailable")
monkeypatch.setattr(secret_scope, "build_profile_secret_scope", boom)
assert adapter.check_requirements() is False
def test_scope_snapshot_is_built_once_and_cached(self, monkeypatch):
import plugins.platforms.buzz.adapter as adapter
monkeypatch.setenv("BUZZ_RELAY_URL", "wss://relay.example")
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
calls = _install_fake_scope(monkeypatch, {"BUZZ_PRIVATE_KEY": _STUB_KEY})
assert adapter.check_requirements() is True
assert adapter.check_requirements() is True
assert adapter.validate_config(type("Cfg", (), {"extra": {}})()) is True
assert len(calls) == 1, "the external-secret snapshot must be cached"
class TestScopedSemanticsUnchanged:
def test_active_scope_miss_does_not_fall_through_to_unscoped_build(
self, monkeypatch
):
"""A scoped miss must keep returning default: the unscoped build is
only for the no-scope startup gate, never a cross-profile borrow."""
import agent.secret_scope as secret_scope
import plugins.platforms.buzz.adapter as adapter
monkeypatch.setenv("BUZZ_RELAY_URL", "wss://relay.example")
monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False)
calls = _install_fake_scope(
monkeypatch, {"BUZZ_PRIVATE_KEY": _STUB_KEY * 2}
)
token = secret_scope.set_secret_scope({}) # active, empty scope
try:
assert adapter.check_requirements() is False
assert calls == [], "an active scope must shadow the unscoped build"
finally:
secret_scope.reset_secret_scope(token)
+417
View File
@@ -0,0 +1,417 @@
"""Tests for the Photon auth module (device login + dashboard API)."""
from __future__ import annotations
import json
import os
import stat
import threading
import time
from base64 import b64encode
from pathlib import Path
from typing import Any, Dict
from unittest import mock
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"
@pytest.mark.skipif(os.name != "posix", reason="POSIX mode bits only")
def test_save_auth_never_world_readable(tmp_hermes_home: Path) -> None:
"""auth.json must be created 0o600 — no window at process umask."""
photon_auth.store_photon_token("secret-token")
mode = (tmp_hermes_home / "auth.json").stat().st_mode & 0o777
assert mode == 0o600
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_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"
# ---------------------------------------------------------------------------
# Cross-process auth.json lock (issue: photon wrote auth.json without the
# cross-process lock hermes_cli/auth.py's ~15 other writers all use, so a
# concurrent refresh from elsewhere could silently lose photon's update or
# vice versa).
def _hold_auth_lock_then_release(hold_event: threading.Event, release_event: threading.Event) -> None:
from hermes_cli.auth import _auth_store_lock
with _auth_store_lock():
hold_event.set()
release_event.wait(timeout=5)
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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_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_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
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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_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_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)
# interval=0 falls back to DEFAULT_POLL_INTERVAL inside the poll loop
# ("sleep first, then poll") — stub the sleep so the test doesn't idle 5s.
monkeypatch.setattr(photon_auth.time, "sleep", lambda _s: None)
token = photon_auth.login_device_flow(open_browser=False)
assert token == "good-token"
assert photon_auth.load_photon_token() == "good-token"
@@ -0,0 +1,120 @@
"""Tests for check_requirements() diagnostic logging (fix) and remaining risks.
Fixed in this file (tests PASS with fix, FAIL without):
- check_requirements() now emits a specific logger.warning for each False
condition so gateway logs pinpoint the exact failure reason.
Remaining risks documented here (still open separate issues):
Risk 2 node_modules dir exists but EMPTY (partial/aborted npm install)
check_requirements() returns True (false positive)
Risk 3 _install_sidecar() subprocess.run calls carry no capture_output /
stdout / stderr npm error output is unrecoverable after the run
"""
from __future__ import annotations
import logging
import shutil
import types
from pathlib import Path
import pytest
from plugins.platforms.photon import adapter as adapter_mod
from plugins.platforms.photon import cli as cli_mod
# ---------------------------------------------------------------------------
# Helpers / shared marks
# ---------------------------------------------------------------------------
_NODE_ON_PATH = shutil.which("node") is not None
_requires_node = pytest.mark.skipif(
not _NODE_ON_PATH,
reason="requires node on PATH to isolate the node_modules check",
)
_requires_node_for_false_positive = pytest.mark.skipif(
not _NODE_ON_PATH,
reason="requires node on PATH so the false-positive path is reachable",
)
# ---------------------------------------------------------------------------
# Fix verification — each False branch now emits a specific warning
# ---------------------------------------------------------------------------
def test_fix_logs_warning_when_httpx_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""When httpx is not installed, check_requirements() must log a warning
that names the missing package so the operator knows what to install."""
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", False)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
(tmp_path / "node_modules").mkdir()
with caplog.at_level(logging.WARNING, logger="plugins.platforms.photon.adapter"):
result = adapter_mod.check_requirements()
assert result is False
messages = [r.message for r in caplog.records]
assert any("httpx" in m for m in messages), (
f"Expected a warning mentioning 'httpx', got: {messages}"
)
# ---------------------------------------------------------------------------
# Risk 2 (open) — empty node_modules directory is a false positive
# ---------------------------------------------------------------------------
@_requires_node_for_false_positive
def test_risk2_fix_empty_node_modules_no_longer_passes_guard(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""npm may create node_modules/ before aborting (network timeout, ENOSPC,
EACCES). Previously an empty directory passed the only filesystem guard in
check_requirements() returning True with a broken sidecar installation.
Fixed: check_requirements() now verifies node_modules/spectrum-ts exists,
so a partial/empty node_modules/ correctly returns False."""
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
# NS-606: disable the connect-time self-heal branch so the guard itself
# (empty node_modules must not read as installed) is what's under test.
monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False)
(tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent
# Fix verified: False instead of the old false-positive True.
assert adapter_mod.check_requirements() is False
# ---------------------------------------------------------------------------
# Risk 3 fix — npm stderr is captured, persisted, and surfaced by check_requirements
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Shared predicate — status / _start_sidecar / check_requirements must agree
# ---------------------------------------------------------------------------
def test_cli_status_shares_adapter_sidecar_deps_check(tmp_path: Path) -> None:
"""`hermes photon status` must use the exact same spectrum-ts check as
check_requirements() / _start_sidecar() not a separate node_modules-only
existence check that would disagree on a partial/empty install."""
assert cli_mod.sidecar_deps_installed is adapter_mod.sidecar_deps_installed
def test_sidecar_deps_installed_false_on_empty_node_modules(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
(tmp_path / "node_modules").mkdir() # empty — spectrum-ts absent
assert adapter_mod.sidecar_deps_installed() is False
@@ -0,0 +1,150 @@
"""Photon's fatal-error notification must not be cancelled by its own teardown.
`_monitor_sidecar_health` and `_supervise_sidecar` run as long-lived tasks on
the adapter. When one of them detects a fatal condition, the gateway's handler
tears the adapter down, and `disconnect()` cancels `_sidecar_health_task` and
awaits it. If the notification is awaited inline on the health task's own call
stack, `disconnect()` ends up cancelling its own ultimate caller: the
`task is not asyncio.current_task()` guard in `disconnect()` compares against
the wrapper task the gateway created around `disconnect()`, not the health task
several plain-await frames further up, so the guard passes and the cancel lands.
`CancelledError` stopped subclassing `Exception` in Python 3.8, so the
`except Exception` that used to wrap the inline notify call never saw it. The
health task died silently mid-handoff -- no log line, no retry -- and the
platform stayed stranded until someone restarted the gateway by hand.
PR #69112 hardened the shared dispatch path in `gateway/run.py` against a
cancelled *caller*. These tests cover the Photon-specific self-cancellation one
layer down, which that PR's scope could not reach.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
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")
return PhotonAdapter(PlatformConfig(enabled=True, token="", extra={}))
class TestFatalNotifyIsDetached:
"""The notification must outlive cancellation of the task that raised it."""
@pytest.mark.asyncio
async def test_health_task_cancellation_does_not_kill_notification(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A fatal handler that cancels the health task (exactly what
``disconnect()`` does) must still see the notification delivered."""
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
adapter._sidecar_health_interval = 0
delivered = asyncio.Event()
async def fake_notify() -> None:
# Mirror the real handler: tear the adapter down, cancelling the
# health task, then finish the handoff.
await adapter.disconnect()
delivered.set()
monkeypatch.setattr(adapter, "_notify_fatal_error", fake_notify)
monkeypatch.setattr(adapter, "_stop_sidecar", lambda: _noop())
async def degraded(_path: str, _payload: Dict[str, Any]) -> Dict[str, Any]:
return {"stream": {"ok": False, "state": "degraded", "degradedForMs": 4000,
"lastIssue": "stream persistently failing"}}
monkeypatch.setattr(adapter, "_sidecar_call", degraded)
health = asyncio.create_task(adapter._monitor_sidecar_health())
adapter._sidecar_health_task = health
await asyncio.wait_for(delivered.wait(), timeout=5.0)
assert adapter.has_fatal_error
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
assert adapter.fatal_error_retryable
# With the dispatch detached, the health task reaches its own `break`
# and returns cleanly instead of being cancelled out from under the
# handoff. Either way it must not die with an unhandled exception --
# that was the silent failure that stranded the platform.
await asyncio.wait_for(asyncio.shield(health), timeout=2.0)
assert health.done()
if not health.cancelled():
assert health.exception() is None
@pytest.mark.asyncio
async def test_dispatch_does_not_await_on_caller_stack(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``_dispatch_fatal_notification`` must return without awaiting, so a
cancel aimed at the calling task cannot reach the handoff."""
adapter = _make_adapter(monkeypatch)
started = asyncio.Event()
finished = asyncio.Event()
async def slow_notify() -> None:
started.set()
await asyncio.sleep(0.05)
finished.set()
monkeypatch.setattr(adapter, "_notify_fatal_error", slow_notify)
async def caller() -> None:
adapter._dispatch_fatal_notification() # must not block
task = asyncio.create_task(caller())
await task # returns immediately even though notify sleeps
await asyncio.wait_for(started.wait(), timeout=2.0)
task.cancel() # cancelling the caller must not touch the notification
await asyncio.wait_for(finished.wait(), timeout=2.0)
@pytest.mark.asyncio
async def test_notification_failure_is_logged_not_raised(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A failing notification must warn rather than surface as an
unretrieved task exception."""
adapter = _make_adapter(monkeypatch)
async def boom() -> None:
raise RuntimeError("gateway unreachable")
monkeypatch.setattr(adapter, "_notify_fatal_error", boom)
with caplog.at_level("WARNING"):
await adapter._notify_fatal_error_logged()
assert "fatal-error notification failed" in caplog.text
class TestBothCallSitesDetached:
"""Neither fatal path may await the notification inline."""
def test_no_inline_notify_awaits_remain(self) -> None:
"""Guard against a future edit reintroducing the inline await."""
import inspect
from plugins.platforms.photon import adapter as photon_adapter
for name in ("_monitor_sidecar_health", "_supervise_sidecar"):
src = inspect.getsource(getattr(photon_adapter.PhotonAdapter, name))
assert "await self._notify_fatal_error()" not in src, (
f"{name} awaits _notify_fatal_error inline; use "
f"_dispatch_fatal_notification() so disconnect() cannot cancel "
f"its own caller"
)
assert "_dispatch_fatal_notification()" in src
async def _noop() -> None:
return None
@@ -0,0 +1,260 @@
"""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 asyncio
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_read_receipt_does_not_wake_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
receipt = _dm_event("", msg_id="spc-read-1")
receipt["content"] = {
"type": "read",
"targetMessageId": "bot-msg-1",
"targetDirection": "outbound",
}
await adapter._dispatch_inbound(receipt)
assert captured == []
@pytest.mark.asyncio
async def test_dispatch_read_receipt_alias_does_not_wake_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Some spectrum-ts streams label receipts ``read_receipt`` — same drop."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
receipt = _dm_event("", msg_id="spc-read-2")
receipt["content"] = {
"type": "read_receipt",
"targetMessageId": "bot-msg-2",
"targetDirection": "outbound",
}
await adapter._dispatch_inbound(receipt)
assert captured == []
# 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_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"
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_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
# ---------------------------------------------------------------------------
# CAF attachment promotion + U+FFFC placeholder tests
# ---------------------------------------------------------------------------
_CAF_BYTES = b"caff" + b"\x00" * 60 # Minimal CAF header magic
def _caf_attachment_event(
content: Dict[str, Any], msg_id: str = "spc-msg-caf"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
"sender": {"id": "+155****4567"},
"content": {"type": "attachment", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_caf_attachment_named_promoted_to_voice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A named .caf attachment is promoted to VOICE for STT routing."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = _CAF_BYTES
event = _caf_attachment_event(
{
"name": "voice_note.caf",
"mimeType": "audio/x-caf",
"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/x-caf"]
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_fffc_placeholder_no_dispatch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A U+FFFC placeholder text does not trigger a message dispatch."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = _dm_event("\ufffc", msg_id="spc-msg-fffc")
chat_key = event["space"]["id"]
await adapter._dispatch_inbound(event)
assert len(captured) == 0
assert chat_key in adapter._pending_fffc
@pytest.mark.asyncio
async def test_disconnect_cancels_pending_fffc_tasks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""disconnect() cancels any pending U+FFFC placeholder tasks."""
adapter = _make_adapter(monkeypatch)
_capture(adapter, monkeypatch)
await adapter._dispatch_inbound(_dm_event("\ufffc", msg_id="spc-msg-fffc"))
assert len(adapter._pending_fffc) == 1
async def _noop_stop_sidecar():
pass
monkeypatch.setattr(adapter, "_stop_sidecar", _noop_stop_sidecar)
monkeypatch.setattr(adapter, "_inbound_running", False)
monkeypatch.setattr(adapter, "_inbound_task", None)
monkeypatch.setattr(adapter, "_sidecar_health_task", None)
monkeypatch.setattr(adapter, "_http_client", None)
await adapter.disconnect()
assert len(adapter._pending_fffc) == 0
@@ -0,0 +1,105 @@
"""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_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_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,117 @@
"""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_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"
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,122 @@
"""Multiplex secondary-profile scope tests for the Photon adapter + auth module.
__init__'s project_id, check_requirements'/validate_config's node_bin/
project_id, _env_enablement's home_channel, _reactions_enabled's
PHOTON_REACTIONS, __init__'s require_mention, and _standalone_send's
sidecar_port, plus auth.py's load_project_credentials/
load_dashboard_project_id, all previously read raw os.getenv
unconditionally (only PHOTON_PROJECT_SECRET/PHOTON_SIDECAR_TOKEN were
already scoped via _get_scoped_secret). Under gateway.multiplex_profiles,
os.environ holds the DEFAULT profile's YAML-to-env bridge output -- a
secondary profile with its own (different or absent) Photon config could
silently authenticate against the default profile's Spectrum project, or
have its mention-gating/reaction behavior driven by the default profile's
settings.
Notably project_id was a stronger variant of the bug (like the IRC fix in
this series): __init__'s original
`os.getenv("PHOTON_PROJECT_ID") or extra.get("project_id") or stored_id`
ordering let a raw env read override even an explicitly configured
config.yaml extra.
Mirrors the LINE/DingTalk/IRC/Mattermost fix for #98738.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import auth as photon_auth
from plugins.platforms.photon.adapter import PhotonAdapter
_PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_REQUIRE_MENTION",
"PHOTON_REACTIONS",
"PHOTON_HOME_CHANNEL",
"PHOTON_HOME_CHANNEL_NAME",
"PHOTON_SIDECAR_PORT",
)
@pytest.fixture
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Isolate from the real ~/.hermes/auth.json fallback in load_project_credentials()."""
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for key in _PHOTON_ENV:
monkeypatch.delenv(key, raising=False)
yield home
for key in _PHOTON_ENV:
os.environ.pop(key, None)
@pytest.fixture
def multiplex_scope():
"""Install multiplex + a secondary-profile secret scope; restore after."""
tokens = []
def install(scope=None):
from agent.secret_scope import set_multiplex_active, set_secret_scope
set_multiplex_active(True)
tokens.append(set_secret_scope(scope or {}))
return tokens[-1]
yield install
from agent.secret_scope import reset_secret_scope, set_multiplex_active
for token in reversed(tokens):
reset_secret_scope(token)
set_multiplex_active(False)
@pytest.fixture
def default_profile_env(monkeypatch):
"""The default profile's YAML-to-env bridge output in os.environ."""
monkeypatch.setenv("PHOTON_PROJECT_ID", "default-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "default-project-secret")
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
monkeypatch.setenv("PHOTON_REACTIONS", "true")
class TestAuthMultiplexProfileScope:
"""load_project_credentials / load_dashboard_project_id (auth.py)."""
def test_scoped_miss_does_not_leak_default_project_id(
self, tmp_hermes_home, multiplex_scope, default_profile_env
):
multiplex_scope({"SOMETHING_ELSE": "x"})
sid, secret = photon_auth.load_project_credentials()
assert sid is None
assert secret is None
adapter = PhotonAdapter(PlatformConfig(enabled=True, extra={}))
assert adapter._project_id == ""
assert adapter.require_mention is False
assert adapter._reactions_enabled() is False
class TestAdapterMultiplexProfileScope:
"""PhotonAdapter.__init__ / _env_enablement / _reactions_enabled (adapter.py)."""
def test_secondary_extra_wins_over_default_profile_env(
self, tmp_hermes_home, multiplex_scope, default_profile_env
):
"""A secondary profile's own config.yaml extra project_id must be
authoritative -- not the default profile's bridged env value. The
pre-fix ordering (raw os.getenv checked BEFORE extra) meant even an
explicit extra config was silently overridden."""
multiplex_scope({"PHOTON_PROJECT_SECRET": "profile-secret"})
cfg = PlatformConfig(
enabled=True,
extra={"project_id": "profile-project-id"},
)
adapter = PhotonAdapter(cfg)
assert adapter._project_id == "profile-project-id"
@@ -0,0 +1,238 @@
"""Regression tests for the npm stderr capture + error log persistence fix.
Each test covers a specific failure vector introduced by the Risk 3 solution:
1. _install_sidecar() return code unchanged still 0 on success, non-zero on failure
2. _install_sidecar() with no npm on PATH still returns 1, no OSError on log write
3. _NPM_ERROR_LOG write fails (OSError / read-only fs) silently handled, no exception
4. _NPM_ERROR_LOG read fails in check_requirements() silently handled, returns False
5. _NPM_ERROR_LOG is empty string not written, check_requirements() falls back gracefully
6. _NPM_ERROR_LOG from prior failed run exists when next run succeeds cleared
7. check_requirements() with no _NPM_ERROR_LOG debug log still emitted without error detail
8. proc.stderr is None (edge case on some platforms) no AttributeError, no crash
"""
from __future__ import annotations
import logging
import types
from pathlib import Path
import pytest
from plugins.platforms.photon import adapter as adapter_mod
from plugins.platforms.photon import cli as cli_mod
_NODE_ON_PATH = __import__("shutil").which("node") is not None
_requires_node = pytest.mark.skipif(
not _NODE_ON_PATH, reason="requires node on PATH"
)
# ---------------------------------------------------------------------------
# 1. Return code contract unchanged
# ---------------------------------------------------------------------------
def test_regression_return_code_zero_on_success(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""_install_sidecar() must still return 0 on npm success."""
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
assert cli_mod._install_sidecar() == 0
# ---------------------------------------------------------------------------
# 2. OSError on log write — silently swallowed, no crash
# ---------------------------------------------------------------------------
def test_regression_oserror_on_log_write_does_not_propagate(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""If writing _NPM_ERROR_LOG raises OSError (read-only fs, permission denied),
_install_sidecar() must NOT propagate the exception it still returns the
npm exit code."""
def _bad_log_write(*args, **kwargs):
raise OSError("read-only file system")
error_log = tmp_path / ".photon-npm-error.log"
# Monkey-patch write_text on the Path object via a subclass
class _UnwritablePath(type(error_log)):
def write_text(self, *a, **kw):
raise OSError("read-only file system")
def unlink(self, *a, **kw):
raise OSError("read-only file system")
def exists(self):
return False
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr="npm ERR!"),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", _UnwritablePath(error_log))
rc = cli_mod._install_sidecar()
assert rc == 1 # still returns the npm exit code
# ---------------------------------------------------------------------------
# 3. OSError on log read in check_requirements() — silently swallowed
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 4. Empty stderr — log file NOT written
# ---------------------------------------------------------------------------
def test_regression_empty_stderr_does_not_write_log(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""If npm fails but stderr is empty (some npm versions), _NPM_ERROR_LOG must
NOT be written an empty file would mislead check_requirements()."""
error_log = tmp_path / ".photon-npm-error.log"
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
cli_mod._install_sidecar()
assert not error_log.exists(), (
"_NPM_ERROR_LOG must not be created when stderr is empty"
)
# ---------------------------------------------------------------------------
# 5. proc.stderr is None — no AttributeError
# ---------------------------------------------------------------------------
def test_regression_permissionerror_on_success_unlink_does_not_propagate(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A successful npm install must still return 0 even if deleting the
stale _NPM_ERROR_LOG raises something other than FileNotFoundError
(e.g. PermissionError on a locked file) the unlink is best-effort."""
error_log = tmp_path / ".photon-npm-error.log"
class _UnremovablePath(type(error_log)):
def unlink(self, *a, **kw):
raise PermissionError("access denied")
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", _UnremovablePath(error_log))
rc = cli_mod._install_sidecar()
assert rc == 0 # PermissionError on cleanup must not fail the install
def test_regression_long_stderr_truncated_before_write(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A huge npm stderr must be bounded before it hits disk, not just when
read back later otherwise a verbose npm failure writes an unbounded
file to the sidecar directory on every retry."""
error_log = tmp_path / ".photon-npm-error.log"
huge_stderr = "npm ERR! " + ("x" * 10_000)
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=huge_stderr),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
cli_mod._install_sidecar()
written = error_log.read_text(encoding="utf-8")
assert len(written) <= cli_mod._NPM_ERROR_LOG_MAX_CHARS
def test_regression_none_stderr_does_not_crash(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""On some platforms/configurations proc.stderr can be None even with
stderr=PIPE (e.g. encoding errors). _install_sidecar() must handle this."""
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=None),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
rc = cli_mod._install_sidecar()
assert rc == 1 # must not raise AttributeError
# ---------------------------------------------------------------------------
# 6. Stale log cleared on success — no phantom errors after reinstall
# ---------------------------------------------------------------------------
def test_regression_stale_log_not_surfaced_after_successful_reinstall(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""If npm install succeeds on a retry but a stale _NPM_ERROR_LOG from the
prior failed run still exists, check_requirements() must NOT surface the
stale error after the successful reinstall clears it."""
error_log = tmp_path / ".photon-npm-error.log"
error_log.write_text("stale: npm ERR! old failure", encoding="utf-8")
# Successful reinstall clears the log
monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm")
monkeypatch.setattr(
cli_mod.subprocess, "run",
lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(cli_mod, "_NPM_ERROR_LOG", error_log)
cli_mod._install_sidecar()
assert not error_log.exists(), "Success must clear the stale error log"
# Now check_requirements() must not mention the old error
# Create spectrum-ts inside node_modules/ — the content check requires it.
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", error_log)
with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"):
result = adapter_mod.check_requirements()
assert result is True
assert not any("stale" in r.message for r in caplog.records)
# ---------------------------------------------------------------------------
# 7. check_requirements() without error log — debug log still emitted
# ---------------------------------------------------------------------------
@_requires_node
def test_regression_debug_log_emitted_even_without_error_log(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""When node_modules is missing and no _NPM_ERROR_LOG exists (first-time
setup, not a failed install), check_requirements() must still emit a DEBUG
line pointing to the sidecar path."""
monkeypatch.setattr(adapter_mod, "HTTPX_AVAILABLE", True)
monkeypatch.setattr(adapter_mod, "_SIDECAR_DIR", tmp_path)
monkeypatch.setattr(adapter_mod, "_NPM_ERROR_LOG", tmp_path / ".photon-npm-error.log")
# NS-606: disable self-heal so the debug-log branch is reached.
monkeypatch.setattr(adapter_mod, "_dir_writable", lambda _p: False)
# node_modules NOT created, error log NOT created
with caplog.at_level(logging.DEBUG, logger="plugins.platforms.photon.adapter"):
result = adapter_mod.check_requirements()
assert result is False
debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
assert any(str(tmp_path) in m for m in debug_messages), (
f"Expected DEBUG with sidecar path even without error log, got: {debug_messages}"
)
@@ -0,0 +1,130 @@
"""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_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,495 @@
"""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
import asyncio
from typing import Any, Dict
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import SendResult
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)
# -- 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
def test_structured_non_retryable_sidecar_error_not_legacy_retried() -> None:
error = str(
photon_adapter.PhotonSidecarError(
path="/send",
status_code=500,
error="internal sidecar error",
error_class="auth_or_config",
retryable=False,
)
)
assert PhotonAdapter._is_retryable_error(error) is False
@pytest.mark.asyncio
async def test_send_with_retry_uses_structured_retryable_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
calls = 0
sleeps: list[float] = []
async def _fake_sleep(delay: float) -> None:
sleeps.append(delay)
async def _fake_sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
nonlocal calls
calls += 1
if calls == 1:
raise photon_adapter.PhotonSidecarError(
path=path,
status_code=500,
error="temporary upstream failure",
error_class="upstream_transient",
retryable=True,
)
return {"ok": True, "messageId": "m-2"}
monkeypatch.setattr(photon_adapter.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(adapter, "_sidecar_call", _fake_sidecar_call)
result = await adapter._send_with_retry(
"space-1", "hello", max_retries=1, base_delay=0.25
)
assert result.success is True
assert result.message_id == "m-2"
assert calls == 2
assert sleeps == [0.25]
# -- 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_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
# The notification is dispatched onto its own task rather than awaited on
# the supervisor's stack, so that disconnect() cancelling the supervisor
# cannot kill the handoff. Let that task run before asserting delivery.
await _drain_pending_tasks()
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
# Dispatched detached (see _dispatch_fatal_notification) so the health
# task's own teardown cannot cancel the handoff; drain before asserting.
await _drain_pending_tasks()
assert notified == [True]
async def _drain_pending_tasks(limit: int = 50) -> None:
"""Let detached fatal-notification tasks finish before asserting on them.
``_dispatch_fatal_notification`` deliberately does not await the
notification (that is what kept ``disconnect()`` from cancelling its own
caller), so a test that drives ``_monitor_sidecar_health`` /
``_supervise_sidecar`` directly returns before the notification has run.
"""
for _ in range(limit):
pending = [
t for t in asyncio.all_tasks()
if t is not asyncio.current_task() and not t.done()
]
if not pending:
return
await asyncio.wait(pending, timeout=1.0)
# -- Gap 5: self-cancellation race in _stop_sidecar() (issue #73159) --------
#
# The tests above mock out _notify_fatal_error() entirely, so the real
# integration chain (supervisor task -> _notify_fatal_error() ->
# disconnect() -> _stop_sidecar() -> cancel the supervisor task) is never
# exercised end to end. That chain is exactly where the bug lives: when
# _notify_fatal_error() is a real callback that calls adapter.disconnect(),
# _stop_sidecar() is invoked FROM WITHIN the currently-running supervisor
# task, and cancelling self._sidecar_supervisor_task there cancels the very
# task executing the fatal-error handler -- aborting it (via CancelledError,
# a BaseException the handler's `except Exception` guards don't catch)
# before the Gateway's reconnect-queue step ever runs.
class _FakeStoppedProc:
"""Minimal proc stand-in so _stop_sidecar() reaches its finally block
(it early-returns entirely when self._sidecar_proc is None) without
spawning a real subprocess or doing any real I/O."""
def __init__(self) -> None:
self.stdin = None
def wait(self, timeout: float | None = None) -> int:
return 0
@pytest.mark.asyncio
async def test_supervisor_task_survives_self_triggered_disconnect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The real chain: _supervise_sidecar() (running as the actual
self._sidecar_supervisor_task) detects the crash and calls a REAL
_notify_fatal_error() that calls adapter.disconnect() -- which reaches
_stop_sidecar() from inside the task it's about to try to cancel.
Before the fix: this raises CancelledError out of _supervise_sidecar(),
so the task ends up in the "cancelled" state and reconnect_queued below
is never set (mirroring how the real Gateway's fatal-error handler,
which runs the reconnect-queue logic AFTER disconnect() returns, never
gets there either).
After the fix: disconnect() completes normally, _supervise_sidecar()
returns normally, and the task is NOT cancelled.
"""
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
# A minimal fake proc so _stop_sidecar() reaches its finally block
# (the real process-management behavior is covered separately by
# test_sidecar_lifecycle.py) -- this test is purely about the
# self-cancellation race.
adapter._sidecar_proc = _FakeStoppedProc()
reconnect_queued: list[bool] = []
async def _real_notify_fatal_error() -> None:
# Stand-in for the Gateway's actual fatal-error handler: it calls
# adapter.disconnect() (real chain: disconnect -> _stop_sidecar,
# which used to self-cancel), then -- only if that completes
# without the CancelledError escaping -- proceeds to queue the
# platform for background reconnection.
await adapter.disconnect()
reconnect_queued.append(True)
monkeypatch.setattr(adapter, "_notify_fatal_error", _real_notify_fatal_error)
async def _run_supervisor():
await adapter._supervise_sidecar(_DeadProc(exit_code=75))
task = asyncio.ensure_future(_run_supervisor())
adapter._sidecar_supervisor_task = task
# Must complete cleanly -- must NOT raise CancelledError out to us.
await task
assert task.cancelled() is False, (
"The supervisor task must not end up cancelled by its own "
"fatal-error handling chain"
)
assert adapter.has_fatal_error is True
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
assert reconnect_queued == [True], (
"The reconnect-queue step (everything after disconnect() returns "
"in the real Gateway handler) must actually run -- this is the "
"exact step issue #73159 reports as silently skipped"
)
# _stop_sidecar() must have cleared the task reference either way.
assert adapter._sidecar_supervisor_task is None
@pytest.mark.asyncio
async def test_external_disconnect_still_cancels_supervisor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The OTHER call path -- external cleanup (Gateway shutdown, an
explicit /platform disconnect) -- calls _stop_sidecar() from a
DIFFERENT task than the supervisor. That legitimate case must still
cancel a still-running supervisor task exactly as before."""
adapter = _make_adapter(monkeypatch)
adapter._sidecar_proc = _FakeStoppedProc()
supervisor_ran_forever = asyncio.Event()
async def _hangs_forever():
try:
supervisor_ran_forever.set()
await asyncio.sleep(3600)
except asyncio.CancelledError:
raise
task = asyncio.ensure_future(_hangs_forever())
adapter._sidecar_supervisor_task = task
await supervisor_ran_forever.wait()
# Called from THIS (different) task -- the external-cleanup case.
await adapter._stop_sidecar()
# cancel() only schedules the CancelledError; await it so the task
# actually settles into the cancelled state before asserting.
try:
await task
except asyncio.CancelledError:
pass
assert task.cancelled() is True, (
"External cleanup must still cancel a running supervisor task"
)
assert adapter._sidecar_supervisor_task is None
# -- target_not_allowed: shared/free-tier outbound-send restriction ----------
#
# Spectrum throws AuthenticationError("Target not allowed for this project")
# from space.send when a shared/free-tier line initiates an outbound send to
# a new target. The sidecar classifies it as the structured code
# `target_not_allowed`; the adapter must treat it as permanent in BOTH
# _send_with_retry and _standalone_send, surfacing the canonical user-facing
# message instead of raw upstream error text (issues #50971 / #51897).
def test_target_not_allowed_maps_to_canonical_message() -> None:
err = photon_adapter._sidecar_error_from_response(
"/send",
500,
'{"ok":false,"error":"internal sidecar error",'
'"error_class":"target_not_allowed","retryable":false}',
)
assert err.error_class == "target_not_allowed"
assert err.retryable is False
assert err.error == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
# No raw upstream text may leak through the structured code path.
assert "Target not allowed for this project" not in str(err)
@pytest.mark.asyncio
async def test_standalone_send_classifies_target_not_allowed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "token")
class _Resp:
status_code = 500
text = (
'{"ok":false,"error":"internal sidecar error",'
'"error_class":"target_not_allowed","retryable":false}'
)
@staticmethod
def json() -> Dict[str, Any]:
return {
"ok": False,
"error": "internal sidecar error",
"error_class": "target_not_allowed",
"retryable": False,
}
class _FakeClient:
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_FakeClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, *a: Any, **k: Any) -> _Resp:
return _Resp()
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
result = await photon_adapter._standalone_send(
PlatformConfig(enabled=True, extra={}), "space-1", "hello",
)
assert result.get("error") == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
assert result.get("error_class") == "target_not_allowed"
assert result.get("retryable") is False
assert "Target not allowed for this project" not in str(result)
@@ -0,0 +1,149 @@
"""Native-poll clarify tests for PhotonAdapter.
iMessage has a native poll bubble (spectrum-ts `poll()` builder). A
multiple-choice ``clarify`` renders as that poll; the user taps a choice and
the vote streams back inbound as a ``poll_option`` event. These tests cover
both directions without spawning the Node sidecar or binding ports:
* outbound ``send_clarify`` with choices POSTs ``/send-poll`` and flips the
clarify into text-capture mode; with no choices it stays plain text;
* inbound a ``poll_option`` selection is dispatched as a plain-text message
carrying the chosen option (so the gateway clarify-intercept resolves it),
a deselection is dropped, and an empty-title vote is dropped.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, SendResult
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 _poll_option_event(
*, title: str, selected: bool = True, msg_id: str = "spc-msg-vote"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"platform": "iMessage",
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
"sender": {"id": "+155****4567"},
"content": {
"type": "poll_option",
"title": title,
"selected": selected,
"pollTitle": "Pick one",
},
"timestamp": "2026-05-14T19:06:32.000Z",
}
# ---------------------------------------------------------------------------
# Inbound: a poll vote becomes the clarify answer.
@pytest.mark.asyncio
async def test_poll_vote_dispatched_as_choice_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A poll selection is forwarded as a plain-text message carrying the
chosen option, so the gateway clarify text-intercept can resolve it."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
await adapter._dispatch_inbound(
_poll_option_event(title="Yes — native tappable buttons")
)
assert len(captured) == 1
ev = captured[0]
assert ev.text == "Yes — native tappable buttons"
assert ev.message_type == MessageType.TEXT
assert ev.source.chat_id == "+155****4567"
# ---------------------------------------------------------------------------
# Outbound: send_clarify renders a native poll for choices.
def _stub_sidecar_poll(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, *, ok: bool = True
) -> List[Tuple[str, str, list]]:
calls: List[Tuple[str, str, list]] = []
async def fake_send_poll(space_id: str, title: str, options: list):
calls.append((space_id, title, list(options)))
return SendResult(
success=ok,
message_id="spc-msg-poll" if ok else None,
error=None if ok else "boom",
)
monkeypatch.setattr(adapter, "_sidecar_send_poll", fake_send_poll)
return calls
def _stub_sidecar_text(
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
) -> List[Tuple[str, str]]:
sends: List[Tuple[str, str]] = []
async def fake_send(space_id: str, text: str):
sends.append((space_id, text))
return SendResult(success=True, message_id="spc-msg-text")
monkeypatch.setattr(adapter, "_sidecar_send", fake_send)
return sends
@pytest.mark.asyncio
async def test_send_clarify_with_choices_sends_native_poll(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
poll_calls = _stub_sidecar_poll(adapter, monkeypatch)
marked: List[str] = []
import tools.clarify_gateway as cg
monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: marked.append(cid))
result = await adapter.send_clarify(
chat_id="+155****4567",
question="Pick one",
choices=["A", "B", "C"],
clarify_id="clar-1",
session_key="sess-1",
)
assert result.success
assert len(poll_calls) == 1
space_id, title, options = poll_calls[0]
assert space_id == "+155****4567"
assert title == "Pick one"
assert options == ["A", "B", "C"]
# The vote returns as text, so text-capture must be enabled.
assert marked == ["clar-1"]
@@ -0,0 +1,108 @@
"""Presence-watchdog tests.
spectrum-ts only reconnects when its inbound iterator throws or ends; a
half-open ("zombie") gRPC socket makes the iterator hang forever (no error, no
end), so inbound silently dies until the sidecar is restarted. The adapter's
presence watchdog probes the upstream channel via the sidecar's ``/probe``
endpoint and respawns the sidecar after repeated probe failures.
These tests exercise the watchdog's decision logic (probe -> count failures ->
respawn; success resets; recent inbound traffic skips the probe) without
spawning Node, binding ports, or hitting the network.
"""
from __future__ import annotations
import time
from typing import Any, List
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon.adapter import PhotonAdapter
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra: Any) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
cfg = PlatformConfig(enabled=True, token="", extra=dict(extra))
return PhotonAdapter(cfg)
def test_probe_config_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
a = _make_adapter(monkeypatch)
# Conservative by default: probe only after 10+ minutes of stream silence
# so quiet shared lines never trigger restart storms.
assert a._probe_interval == 600.0
assert a._probe_timeout == 10.0
assert a._probe_max_failures == 3
assert a._probe_enabled is True
def test_note_activity_resets_failures(monkeypatch: pytest.MonkeyPatch) -> None:
a = _make_adapter(monkeypatch)
a._probe_failures = 2
before = a._last_upstream_activity
time.sleep(0.001)
a._note_upstream_activity()
assert a._probe_failures == 0
assert a._last_upstream_activity > before
@pytest.mark.asyncio
async def test_respawn_after_max_failures(monkeypatch: pytest.MonkeyPatch) -> None:
"""The core fix: N consecutive dead probes -> exactly one respawn."""
a = _make_adapter(monkeypatch, probe_max_failures=3)
respawns: List[str] = []
async def _fake_respawn(reason: str) -> None:
respawns.append(reason)
a._note_upstream_activity() # mirror real respawn (clears failures)
async def _hung_probe() -> str:
return "hung"
monkeypatch.setattr(a, "_respawn_sidecar", _fake_respawn)
monkeypatch.setattr(a, "_probe_once", _hung_probe)
# Simulate the watchdog's per-iteration decision logic directly (no sleeps).
a._last_upstream_activity = time.monotonic() - 999 # force a probe each time
for _ in range(3):
verdict = await a._probe_once()
assert verdict == "hung"
a._probe_failures += 1
if a._probe_failures >= a._probe_max_failures:
await a._respawn_sidecar("test")
assert respawns == ["test"]
assert a._probe_failures == 0 # reset by the (faked) respawn
@pytest.mark.asyncio
async def test_success_resets_failure_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A live probe between dead ones prevents a respawn (failures reset)."""
a = _make_adapter(monkeypatch, probe_max_failures=3)
respawns: List[str] = []
async def _fake_respawn(reason: str) -> None:
respawns.append(reason)
monkeypatch.setattr(a, "_respawn_sidecar", _fake_respawn)
# Two failures, then a success, then two more failures: never hits 3 in a row.
sequence = [False, False, True, False, False]
for alive in sequence:
if alive:
a._note_upstream_activity()
else:
a._probe_failures += 1
if a._probe_failures >= a._probe_max_failures:
await a._respawn_sidecar("should-not-fire")
assert respawns == []
assert a._probe_failures == 2
@@ -0,0 +1,195 @@
"""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"
# -- 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
@@ -0,0 +1,239 @@
"""Rich-link handling tests for PhotonAdapter.
Photon's spectrum-ts SDK exposes a ``richlink()`` content builder for native
URL previews. Hermes routes URL-only outbound messages to the sidecar's
rich-link endpoint and preserves inbound rich-link URLs when Spectrum emits
that content type.
"""
from __future__ import annotations
import base64
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
_URL = "https://example.com/article"
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 _capture_inbound(
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(content: Dict[str, Any], msg_id: str = "spc-msg-rich") -> Dict[str, Any]:
return {
"messageId": msg_id,
"platform": "iMessage",
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
"sender": {"id": "+155****4567"},
"content": content,
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_url_only_send_routes_to_richlink_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
result = await adapter.send("+155****4567", _URL)
assert result.success is True
assert calls == [("/send-richlink", {"spaceId": "+155****4567", "url": _URL})]
@pytest.mark.asyncio
async def test_mixed_prose_url_stays_on_markdown_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+155****4567", f"Read this: {_URL}")
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
assert body["text"] == f"Read this: {_URL}"
@pytest.mark.asyncio
async def test_malformed_url_like_send_stays_on_markdown_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+155****4567", "http://[::1")
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
assert body["text"] == "http://[::1"
@pytest.mark.asyncio
async def test_markdown_link_stays_on_markdown_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter)
await adapter.send("+155****4567", f"[Read this]({_URL})")
path, body = calls[0]
assert path == "/send"
assert body["format"] == "markdown"
@pytest.mark.asyncio
async def test_direct_url_only_send_falls_back_to_plain_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
adapter = _make_adapter(monkeypatch)
calls: List[Tuple[str, Dict[str, Any]]] = []
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
calls.append((path, body))
if path == "/send-richlink":
raise RuntimeError("richlink unsupported")
return {"ok": True, "messageId": "plain-msg"}
adapter._sidecar_call = _fake_call # type: ignore[assignment]
result = await adapter.send("+155****4567", _URL)
assert result.success is True
assert result.message_id == "plain-msg"
assert calls == [
("/send-richlink", {"spaceId": "+155****4567", "url": _URL}),
("/send", {"spaceId": "+155****4567", "text": _URL}),
]
@pytest.mark.asyncio
async def test_standalone_url_only_send_routes_to_richlink_endpoint(
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, "+155****4567", _URL)
assert result.get("success") is True
assert posted == [
(
"http://127.0.0.1:8789/send-richlink",
{"spaceId": "+155****4567", "url": _URL},
)
]
@pytest.mark.asyncio
async def test_inbound_richlink_dispatches_url_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
adapter = _make_adapter(monkeypatch)
captured = _capture_inbound(adapter, monkeypatch)
event = _dm_event({"type": "richlink", "url": _URL})
await adapter._dispatch_inbound(event)
assert len(captured) == 1
assert captured[0].text == _URL
assert captured[0].message_type == MessageType.TEXT
assert captured[0].raw_message["content"] == {"type": "richlink", "url": _URL}
_PNG_1X1_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def _preview_attachment(
name: str = "preview.pluginPayloadAttachment",
mime_type: str = "image/png",
) -> Dict[str, Any]:
raw = base64.b64decode(_PNG_1X1_B64)
return {
"type": "attachment",
"name": name,
"mimeType": mime_type,
"size": len(raw),
"data": _PNG_1X1_B64,
"encoding": "base64",
}
def _preview_attachment_by_id(
attachment_id: str = "doc_123.pluginPayloadAttachment",
) -> Dict[str, Any]:
payload = _preview_attachment(name="")
payload["id"] = attachment_id
payload["name"] = None
return payload
@@ -0,0 +1,232 @@
"""Sidecar runtime-record persistence tests (issue #69960).
The sidecar token is generated at spawn and used to exist only in the
gateway process memory + sidecar child env so cron/`hermes send`
standalone sends structurally could not authenticate. The adapter now
persists ``<hermes-home>/runtime/photon-sidecar.json`` after the sidecar
passes its /healthz readiness check, deletes it on stop/failed-start, and
``_standalone_send`` falls back to it when PHOTON_SIDECAR_TOKEN is unset.
No Node, no ports, no network.
"""
from __future__ import annotations
import asyncio
import json
import os
import stat
import sys
from pathlib import Path
from typing import Any
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
@pytest.fixture()
def record_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "runtime" / "photon-sidecar.json"
monkeypatch.setattr(photon_adapter, "_runtime_record_path", lambda: path)
return path
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_SIDECAR_TOKEN", raising=False)
cfg = PlatformConfig(enabled=True, token="", extra={})
return PhotonAdapter(cfg)
# -- record helpers ----------------------------------------------------------
def test_write_read_delete_roundtrip(record_path: Path) -> None:
photon_adapter._write_runtime_record(8789, "tok123", 4242)
assert record_path.exists()
data = json.loads(record_path.read_text(encoding="utf-8"))
assert data == {"port": 8789, "token": "tok123", "pid": 4242}
assert photon_adapter._read_runtime_record() == data
photon_adapter._delete_runtime_record()
assert not record_path.exists()
# Idempotent: deleting a missing record must not raise.
photon_adapter._delete_runtime_record()
assert photon_adapter._read_runtime_record() is None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
def test_record_written_with_0600(record_path: Path) -> None:
photon_adapter._write_runtime_record(8789, "secret", 1)
mode = stat.S_IMODE(record_path.stat().st_mode)
assert mode == 0o600
def test_read_tolerates_corrupt_record(record_path: Path) -> None:
record_path.parent.mkdir(parents=True, exist_ok=True)
record_path.write_text("{not json", encoding="utf-8")
assert photon_adapter._read_runtime_record() is None
# -- lifecycle: written after healthz success, removed on stop/failure -------
class _HealthzClient:
"""Fake httpx.AsyncClient whose /healthz response is injectable."""
status_code = 200
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_HealthzClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, *a: Any, **k: Any) -> Any:
cls = type(self)
class _Resp:
status_code = cls.status_code
return _Resp()
class _FakeProc:
pid = 4242
stdin = None
returncode: int | None = None
def poll(self) -> int | None:
return None
def wait(self, timeout: float | None = None) -> int:
return 0
def terminate(self) -> None:
pass
def kill(self) -> None:
pass
def _patch_spawn(
monkeypatch: pytest.MonkeyPatch, adapter: PhotonAdapter, tmp_path: Path
) -> None:
"""Stub everything _start_sidecar touches before the healthz loop."""
sidecar_dir = tmp_path / "sidecar"
# sidecar_deps_installed() checks the dependency's own directory, not just
# node_modules/ (9cf2046081) — mirror a real completed install.
(sidecar_dir / "node_modules" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar_dir)
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
async def _no_reap(self: PhotonAdapter) -> None:
return None
monkeypatch.setattr(PhotonAdapter, "_reap_stale_sidecar", _no_reap)
monkeypatch.setattr(
photon_adapter.subprocess,
"run",
lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
)
monkeypatch.setattr(
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
)
async def _no_supervise(self: PhotonAdapter, proc: Any) -> None:
return None
monkeypatch.setattr(PhotonAdapter, "_supervise_sidecar", _no_supervise)
@pytest.mark.asyncio
async def test_record_written_after_healthz_success(
monkeypatch: pytest.MonkeyPatch, record_path: Path, tmp_path: Path
) -> None:
adapter = _make_adapter(monkeypatch)
_patch_spawn(monkeypatch, adapter, tmp_path)
_HealthzClient.status_code = 200
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthzClient)
await adapter._start_sidecar()
data = json.loads(record_path.read_text(encoding="utf-8"))
assert data["port"] == adapter._sidecar_port
assert data["token"] == adapter._sidecar_token
assert data["pid"] == 4242
# Cleanup so the fake supervisor task doesn't leak between tests.
if adapter._sidecar_supervisor_task is not None:
adapter._sidecar_supervisor_task.cancel()
@pytest.mark.asyncio
async def test_stop_without_proc_still_clears_record(
monkeypatch: pytest.MonkeyPatch, record_path: Path
) -> None:
adapter = _make_adapter(monkeypatch)
photon_adapter._write_runtime_record(8789, "tok", 4242)
adapter._sidecar_proc = None
await adapter._stop_sidecar()
assert not record_path.exists()
# -- _standalone_send fallback ------------------------------------------------
class _SendClient:
"""Fake httpx.AsyncClient capturing /send calls."""
calls: list = []
def __init__(self, *a: Any, **k: Any) -> None:
pass
async def __aenter__(self) -> "_SendClient":
return self
async def __aexit__(self, *a: Any) -> bool:
return False
async def post(self, url: str, json: Any = None, headers: Any = None) -> Any:
type(self).calls.append((url, json, headers))
class _Resp:
status_code = 200
text = ""
@staticmethod
def json() -> dict:
return {"ok": True, "messageId": "m1"}
return _Resp()
@pytest.mark.asyncio
async def test_standalone_send_consumes_record_when_env_missing(
monkeypatch: pytest.MonkeyPatch, record_path: Path
) -> None:
monkeypatch.delenv("PHOTON_SIDECAR_TOKEN", raising=False)
monkeypatch.delenv("PHOTON_SIDECAR_PORT", raising=False)
photon_adapter._write_runtime_record(9111, "record-token", os.getpid())
_SendClient.calls = []
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _SendClient)
result = await photon_adapter._standalone_send(
PlatformConfig(enabled=True, token="", extra={}), "+15551234567", "hi"
)
assert result == {"success": True, "message_id": "m1"}
url, _body, headers = _SendClient.calls[0]
assert ":9111/" in url
assert headers["X-Hermes-Sidecar-Token"] == "record-token"
@@ -0,0 +1,194 @@
"""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_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_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# Token validation (added for #72763) would otherwise hit the network.
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
# 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")
# No existing credentials — first-time setup path.
monkeypatch.setattr(
cli.photon_auth, "load_project_credentials", lambda: (None, None),
)
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": "+155****4567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
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="+155****4567",
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
assert "new secret saved" in out
assert "restart it so the sidecar" in out
def test_setup_reuses_valid_existing_secret(
monkeypatch: pytest.MonkeyPatch, capsys,
) -> None:
"""Re-running setup with a valid existing secret must NOT regenerate it."""
regenerate_called = False
def _fake_regenerate(token, dashboard_id):
nonlocal regenerate_called
regenerate_called = True
return "new_secret"
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# Token validation (added for #72763) would otherwise hit the network.
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"load_project_credentials",
lambda: ("dashboard", "existing_secret"),
)
# list_users succeeds — existing secret is valid.
monkeypatch.setattr(
cli.photon_auth, "list_users", lambda pid, secret: [],
)
monkeypatch.setattr(cli.photon_auth, "regenerate_project_secret", _fake_regenerate)
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": "+155****4567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
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="+155****4567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
assert not regenerate_called, "regenerate_project_secret must not be called when existing creds are valid"
out = capsys.readouterr().out
assert "existing credentials valid" in out
assert "restart" not in out.lower()
def test_setup_regenerates_when_existing_secret_invalid(
monkeypatch: pytest.MonkeyPatch, capsys,
) -> None:
"""When existing credentials are invalid, setup must regenerate."""
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# Token validation (added for #72763) would otherwise hit the network.
monkeypatch.setattr(cli.photon_auth, "check_photon_token_valid", lambda token: True)
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"load_project_credentials",
lambda: ("dashboard", "stale_secret"),
)
# list_users fails — existing secret is invalid.
monkeypatch.setattr(
cli.photon_auth,
"list_users",
lambda pid, secret: (_ for _ in ()).throw(RuntimeError("auth failed")),
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
lambda token, dashboard_id: "new_secret",
)
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": "+155****4567"}, True),
)
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+155****4321")
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="+155****4567",
first_name=None,
last_name=None,
email=None,
no_browser=True,
skip_sidecar_install=False,
)
)
assert rc == 0
out = capsys.readouterr().out
assert "new secret saved" in out
assert "restart it so the sidecar" 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,211 @@
"""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_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" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
spawned: Dict[str, Any] = {}
hidden_flags = 0x08000000
monkeypatch.setattr(
"hermes_cli._subprocess_compat.windows_hide_flags",
lambda: hidden_flags,
)
class _PatchResult:
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd: List[str], **kwargs: Any) -> _PatchResult:
spawned["patch_cmd"] = cmd
spawned["patch_kwargs"] = kwargs
return _PatchResult()
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
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"
assert spawned["patch_kwargs"]["creationflags"] == hidden_flags
assert kwargs["creationflags"] == hidden_flags
@pytest.mark.asyncio
async def test_spectrum_patch_runs_off_the_event_loop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The node patch run must not block the shared gateway event loop.
``_start_sidecar`` spawns the Spectrum patch script and *waits* for it
(``timeout=10``). Run inline it holds the loop for that whole window, so
every other platform's traffic stalls — and ``_start_sidecar`` runs on
every reconnect (``connect(is_reconnect=True)``), not just startup, so the
stall recurs on a live gateway. The dep reinstall a few lines above already
hops to a thread for exactly this reason; the patch run must too.
"""
import threading
adapter = _make_adapter(monkeypatch)
main_thread = threading.current_thread()
seen: Dict[str, Any] = {}
# node_modules present + deps fresh, so we reach the patch run.
monkeypatch.setattr(photon_adapter.Path, "exists", lambda self: True)
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
async def _no_reap() -> None:
return None
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
def _fake_run(*a: Any, **k: Any) -> Any:
seen["thread"] = threading.current_thread()
class _Done:
returncode = 0
stdout = ""
stderr = ""
return _Done()
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
class _FakeProc:
pid = 4242
stdin = None
stdout = None
def poll(self) -> int:
# Report "exited" so the readiness health-poll loop bails out
# immediately instead of spinning for its full 15s deadline —
# the assertion below only cares where the patch run executed.
return 0
monkeypatch.setattr(
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
)
try:
await adapter._start_sidecar()
except Exception:
# Readiness/handshake past the patch run may fail under the fakes —
# irrelevant here; we only assert where the patch run executed.
pass
assert seen.get("thread") is not None, "patch run never executed"
assert seen["thread"] is not main_thread, (
"Spectrum patch subprocess ran on the event-loop thread; it must be "
"dispatched via asyncio.to_thread so a 10s node spawn can't freeze "
"every other platform on the gateway loop"
)
@@ -0,0 +1,139 @@
"""Tests for the Photon sidecar directory resolver (NS-606).
Hosted/managed images keep the plugin tree under an immutable
``/opt/hermes``; ``resolve_sidecar_dir`` must run in place when the deps are
baked and current, and mirror the sidecar to the writable ``HERMES_HOME``
volume when a runtime install is unavoidable.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import plugins.platforms.photon.sidecar_paths as sidecar_paths
def _seed_source(source: Path, *, with_node_modules: bool = False) -> None:
source.mkdir(parents=True, exist_ok=True)
for name in sidecar_paths._MIRROR_FILES:
(source / name).write_text(f"// {name}\n", encoding="utf-8")
if with_node_modules:
(source / "node_modules").mkdir()
(source / "node_modules" / ".package-lock.json").write_text(
"{}", encoding="utf-8"
)
def _freeze_writability(monkeypatch, *, writable: bool) -> None:
monkeypatch.setattr(sidecar_paths, "_dir_writable", lambda _p: writable)
def test_env_override_wins(tmp_path, monkeypatch) -> None:
override = tmp_path / "custom"
monkeypatch.setenv("PHOTON_SIDECAR_DIR", str(override))
assert sidecar_paths.resolve_sidecar_dir(tmp_path / "src") == override
def test_writable_source_runs_in_place(tmp_path, monkeypatch) -> None:
"""Dev installs: writable tree keeps today's behavior exactly."""
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
source = tmp_path / "src"
_seed_source(source)
_freeze_writability(monkeypatch, writable=True)
assert sidecar_paths.resolve_sidecar_dir(source) == source
def test_readonly_source_with_baked_fresh_deps_runs_in_place(
tmp_path, monkeypatch
) -> None:
"""Managed-image happy path: deps baked at build time, no mirror needed."""
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
source = tmp_path / "src"
_seed_source(source, with_node_modules=True)
# Marker newer than lockfile == fresh install.
lock = source / "package-lock.json"
marker = source / "node_modules" / ".package-lock.json"
os.utime(lock, (1000.0, 1000.0))
os.utime(marker, (2000.0, 2000.0))
_freeze_writability(monkeypatch, writable=False)
assert sidecar_paths.resolve_sidecar_dir(source) == source
def test_mirror_refresh_updates_changed_files_and_keeps_node_modules(
tmp_path, monkeypatch
) -> None:
"""Image update changes index.mjs → re-copied; installed deps survive."""
monkeypatch.delenv("PHOTON_SIDECAR_DIR", raising=False)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
source = tmp_path / "src"
_seed_source(source)
_freeze_writability(monkeypatch, writable=False)
mirror = sidecar_paths.resolve_sidecar_dir(source)
# Simulate a completed npm install in the mirror.
(mirror / "node_modules").mkdir()
(mirror / "node_modules" / "installed.txt").write_text("x", encoding="utf-8")
# Image update rewrites a source file.
(source / "index.mjs").write_text("// index.mjs v2\n", encoding="utf-8")
resolved = sidecar_paths.resolve_sidecar_dir(source)
assert resolved == mirror
assert (mirror / "index.mjs").read_text(encoding="utf-8") == "// index.mjs v2\n"
assert (mirror / "node_modules" / "installed.txt").exists()
def test_dir_writable_probe(tmp_path) -> None:
assert sidecar_paths.dir_writable(tmp_path) is True
ro = tmp_path / "ro"
ro.mkdir()
ro.chmod(0o555)
try:
if os.geteuid() == 0: # pragma: no cover - root ignores perms
pytest.skip("root bypasses directory permissions")
assert sidecar_paths.dir_writable(ro) is False
finally:
ro.chmod(0o755)
def test_adapter_import_does_not_resolve_sidecar_dir(monkeypatch) -> None:
"""Importing the adapter must not probe the filesystem or mirror files.
resolve_sidecar_dir() touch/unlink-probes the source tree and may copy
files to HERMES_HOME; the adapter and CLI resolve lazily on first use so
a bare import (plugin discovery, `hermes --help`, test collection) has
no filesystem side effects.
"""
import importlib
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon import cli as photon_cli
def _boom(*args, **kwargs): # pragma: no cover - failure path
raise AssertionError("resolve_sidecar_dir called at import time")
monkeypatch.setattr(sidecar_paths, "resolve_sidecar_dir", _boom)
try:
importlib.reload(photon_adapter)
importlib.reload(photon_cli)
# Nothing resolved yet.
assert photon_adapter._SIDECAR_DIR is None
assert photon_cli._SIDECAR_DIR is None
# First real use resolves (and would call resolve_sidecar_dir).
with pytest.raises(AssertionError, match="import time"):
photon_adapter._sidecar_dir()
# A monkeypatched _SIDECAR_DIR (the pattern existing tests use) is
# honored without touching the resolver.
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", Path("/tmp/x"))
assert photon_adapter._sidecar_dir() == Path("/tmp/x")
assert photon_adapter._npm_error_log() == Path("/tmp/x/.photon-npm-error.log")
finally:
# Restore real bindings for any later test importing these modules.
monkeypatch.undo()
importlib.reload(photon_adapter)
importlib.reload(photon_cli)
@@ -0,0 +1,277 @@
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import textwrap
import time
import urllib.request
from pathlib import Path
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
def _sidecar_env(port: int) -> dict[str, str]:
return {
**os.environ,
"PHOTON_PROJECT_ID": "test-project",
"PHOTON_PROJECT_SECRET": "test-secret",
"PHOTON_SIDECAR_PORT": str(port),
"PHOTON_SIDECAR_TOKEN": "test-token",
}
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _write_sidecar_fixture(tmp_path: Path, *, sdk_available: bool) -> Path:
sidecar = tmp_path / "sidecar"
sidecar.mkdir()
shutil.copyfile("plugins/platforms/photon/sidecar/index.mjs", sidecar / "index.mjs")
# index.mjs imports sibling helper modules — copy every non-patch .mjs so
# the fixture keeps working as helpers are extracted from index.mjs.
for helper in Path("plugins/platforms/photon/sidecar").glob("*.mjs"):
if helper.name in ("index.mjs", "patch-spectrum-mixed-attachments.mjs"):
continue
shutil.copyfile(helper, sidecar / helper.name)
(sidecar / "patch-spectrum-mixed-attachments.mjs").write_text(
"export function patchSpectrumTs() { throw new Error('forced patch failure'); }\n",
encoding="utf-8",
)
if not sdk_available:
return sidecar
package = sidecar / "node_modules" / "spectrum-ts"
(package / "providers").mkdir(parents=True)
(package / "package.json").write_text(
json.dumps(
{
"name": "spectrum-ts",
"type": "module",
"exports": {
".": "./index.js",
"./providers/imessage": "./providers/imessage.js",
},
}
),
encoding="utf-8",
)
(package / "index.js").write_text(
textwrap.dedent(
"""
export async function Spectrum() {
return {
messages: { [Symbol.asyncIterator]() { return { next: () => new Promise(() => {}) }; } },
stop: async () => undefined,
};
}
export const attachment = value => value;
export const voice = value => value;
export const text = value => value;
export const markdown = value => value;
export const typing = value => value;
"""
).lstrip(),
encoding="utf-8",
)
(package / "providers" / "imessage.js").write_text(
"export function imessage() { return {}; }\nimessage.config = () => ({});\n",
encoding="utf-8",
)
return sidecar
def test_sidecar_patch_failure_still_reaches_health_endpoint(tmp_path: Path) -> None:
"""The compatibility patch is optional when the SDK itself remains usable."""
sidecar = _write_sidecar_fixture(tmp_path, sdk_available=True)
port = _free_port()
proc = subprocess.Popen(
["node", "index.mjs"],
cwd=sidecar,
env=_sidecar_env(port),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
request = urllib.request.Request(
f"http://127.0.0.1:{port}/healthz",
data=b"{}",
headers={"X-Hermes-Sidecar-Token": "test-token"},
method="POST",
)
try:
deadline = time.monotonic() + 5
while True:
try:
with urllib.request.urlopen(request, timeout=0.5) as response:
payload = json.load(response)
break
except OSError:
if proc.poll() is not None or time.monotonic() >= deadline:
raise
time.sleep(0.05)
assert payload["ok"] is True
assert proc.poll() is None
finally:
proc.terminate()
_, stderr = proc.communicate(timeout=5)
assert "forced patch failure" in stderr
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
@@ -0,0 +1,12 @@
"""Regression tests for Photon adapter streaming behavior."""
from plugins.platforms.photon.adapter import PhotonAdapter
def test_photon_adapter_does_not_support_message_editing() -> None:
"""PhotonAdapter.SUPPORTS_MESSAGE_EDITING must be False.
Photon (iMessage) has no real edit API for already-sent messages.
This attribute signals the gateway to suppress the streaming cursor
instead of leaving a stale tofu square () behind when edit attempts fail.
"""
assert PhotonAdapter.SUPPORTS_MESSAGE_EDITING is False
@@ -0,0 +1,87 @@
"""Behavior tests for Photon raw-URL outbound routing (issue: markdown 500s).
The iMessage markdown builder enables data detection inside spectrum-ts. On
some IMAgentKit sends, that path returns a 500 when the message contains a raw
URL. The sidecar keeps markdown rendering for URL-free messages, but must use
plain text for messages containing URLs so iMessage can auto-link them without
hitting the data-detection failure path.
The routing decision lives in
``plugins/platforms/photon/sidecar/send-format.mjs`` (imported by index.mjs's
``/send`` handler). These tests *execute* that real module under node and
assert the chosen builder for representative payloads they do not read the
sidecar source.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Dict, Tuple
import pytest
_MODULE = Path("plugins/platforms/photon/sidecar/send-format.mjs").resolve()
_CASES: Dict[str, Tuple[str, str, str]] = {
# name: (format, text, expected builder)
"markdown_without_url_keeps_markdown": (
"markdown", "**bold** and `code`", "markdown",
),
"markdown_with_https_url_falls_back_to_text": (
"markdown", "see **this**: https://example.com/a?b=1", "text",
),
"markdown_with_http_url_falls_back_to_text": (
"markdown", "http://example.com", "text",
),
"markdown_link_syntax_also_falls_back_to_text": (
"markdown", "[docs](https://example.com/docs)", "text",
),
"markdown_with_uppercase_scheme_falls_back_to_text": (
"markdown", "HTTPS://EXAMPLE.COM is loud", "text",
),
"markdown_bare_domain_without_scheme_keeps_markdown": (
# Only scheme'd URLs trip iMessage's data-detection 500; a bare domain
# is ordinary text and must keep markdown rendering.
"markdown", "ask example.com about *this*", "markdown",
),
"text_format_stays_text": ("text", "plain message", "text"),
"text_format_with_url_stays_text": (
"text", "https://example.com", "text",
),
}
@pytest.fixture(scope="module")
def verdicts() -> Dict[str, str]:
"""Run every case through the real send-format module in one node call."""
harness = (
f"import {{ chooseSendFormat }} from {json.dumps(_MODULE.as_uri())};\n"
"const chunks = [];\n"
"process.stdin.on('data', (c) => chunks.push(c));\n"
"process.stdin.on('end', () => {\n"
" const cases = JSON.parse(Buffer.concat(chunks).toString('utf-8'));\n"
" const out = {};\n"
" for (const [name, [format, text]] of Object.entries(cases)) {\n"
" out[name] = chooseSendFormat(format, text);\n"
" }\n"
" process.stdout.write(JSON.stringify(out));\n"
"});\n"
)
payload = {name: [fmt, text] for name, (fmt, text, _) in _CASES.items()}
run = subprocess.run(
["node", "--input-type=module", "-e", harness],
input=json.dumps(payload),
cwd=Path.cwd(),
text=True,
capture_output=True,
check=False,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
@pytest.mark.parametrize("name", sorted(_CASES))
def test_send_builder_selection(name: str, verdicts: Dict[str, str]) -> None:
_, _, expected = _CASES[name]
assert verdicts[name] == expected
@@ -0,0 +1,231 @@
"""Zombie-stream watchdog tests (half-open gRPC stream, issue #54036).
spectrum-ts only reconnects when its inbound iterator throws or ends; a
half-open ("zombie") socket makes the iterator hang forever no error, no
end so inbound silently dies while the sidecar process looks healthy.
The salvaged design has two layers:
1. Sidecar (node): ``stream-staleness.mjs`` decision rules + a watchdog in
``index.mjs`` that tracks the iterator's last yield, probes only after a
conservative silence threshold, and classifies degraded ONLY when a probe
proves connectivity while the stream is silent (never on silence alone,
never on an inconclusive probe). Degraded feeds the existing exit-75
restart path and the ``staleness`` block on ``/healthz``.
2. Adapter (python): ``_monitor_sidecar_health`` surfaces the new staleness
fields; ``_probe_once`` has strict tri-state semantics (alive / hung /
inconclusive).
These tests execute the real node decision module and drive the adapter
against mocked ``/healthz`` responses style follows
test_overflow_recovery.py / test_spectrum_patch.py. No ports are bound and no
gRPC traffic occurs.
"""
from __future__ import annotations
import asyncio
import json
import subprocess
from pathlib import Path
from typing import Any, Dict
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.photon.adapter import PhotonAdapter
_MODULE = Path("plugins/platforms/photon/sidecar/stream-staleness.mjs").resolve()
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)
# -- Sidecar decision rules (execute the real node module) -------------------
def _run_staleness_harness(script: str) -> Dict[str, Any]:
harness = (
"import { classifyProbeRejection, shouldProbe, isZombieSuspect } "
f"from {json.dumps(_MODULE.as_uri())};\n"
+ script
)
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
return json.loads(run.stdout)
def test_probe_rejection_classification_is_strict() -> None:
"""Only not-found-shaped rejections prove liveness; everything else is
inconclusive a rejected probe is NEVER treated as alive (#45580's
original /probe treated any rejection as alive, which was too loose)."""
out = _run_staleness_harness(
"""
const results = {
notFoundCode: classifyProbeRejection({ code: 5, message: "5 NOT_FOUND: nope" }),
notFoundText: classifyProbeRejection(new Error("message not found")),
sdkNotFound: classifyProbeRejection({ code: "notFound", message: "missing" }),
unavailable: classifyProbeRejection({ code: 14, message: "14 UNAVAILABLE: connect failed" }),
deadline: classifyProbeRejection({ code: 4, message: "4 DEADLINE_EXCEEDED" }),
generic: classifyProbeRejection(new Error("socket hang up")),
weird: classifyProbeRejection("string error"),
};
process.stdout.write(JSON.stringify(results));
"""
)
# Completed round-trips (server said not-found for our synthetic id).
for name in ("notFoundCode", "notFoundText", "sdkNotFound"):
assert out[name]["alive"] is True, name
assert out[name]["inconclusive"] is False, name
# Everything else: not alive AND explicitly inconclusive.
for name in ("unavailable", "deadline", "generic", "weird"):
assert out[name]["alive"] is False, name
assert out[name]["inconclusive"] is True, name
def test_should_probe_requires_silence_past_threshold_and_cooldown() -> None:
out = _run_staleness_harness(
"""
const MIN10 = 10 * 60 * 1000;
const results = {
quietButUnderThreshold: shouldProbe(MIN10 - 1, MIN10, MIN10, 120000),
pastThreshold: shouldProbe(MIN10 + 1, MIN10, MIN10, 120000),
pastThresholdButCoolingDown: shouldProbe(MIN10 + 1, MIN10, 1000, 120000),
watchdogDisabled: shouldProbe(MIN10 * 100, 0, MIN10, 120000),
watchdogDisabledNegative: shouldProbe(MIN10 * 100, -1, MIN10, 120000),
};
process.stdout.write(JSON.stringify(results));
"""
)
assert out["quietButUnderThreshold"] is False
assert out["pastThreshold"] is True
assert out["pastThresholdButCoolingDown"] is False
assert out["watchdogDisabled"] is False
assert out["watchdogDisabledNegative"] is False
def test_zombie_requires_probe_proven_connectivity_never_silence_alone() -> None:
"""The core conservatism rule: shared lines can be quiet for hours, so a
zombie is declared only when the stream is silent past threshold AND a
probe PROVED the wire works (stream dead, channel alive)."""
out = _run_staleness_harness(
"""
const MIN10 = 10 * 60 * 1000;
const alive = { alive: true };
const inconclusive = { alive: false };
const results = {
silentAndProbeAlive: isZombieSuspect(MIN10 * 2, MIN10, alive),
silentButProbeInconclusive: isZombieSuspect(MIN10 * 2, MIN10, inconclusive),
silentNoProbe: isZombieSuspect(MIN10 * 2, MIN10, null),
hoursOfSilenceInconclusive: isZombieSuspect(MIN10 * 36, MIN10, inconclusive),
notSilentEnough: isZombieSuspect(MIN10 - 1, MIN10, alive),
disabled: isZombieSuspect(MIN10 * 2, 0, alive),
};
process.stdout.write(JSON.stringify(results));
"""
)
assert out["silentAndProbeAlive"] is True
# Silence alone — even 6 hours of it — is NEVER a zombie verdict.
assert out["silentButProbeInconclusive"] is False
assert out["silentNoProbe"] is False
assert out["hoursOfSilenceInconclusive"] is False
assert out["notSilentEnough"] is False
assert out["disabled"] is False
# -- Adapter surfacing of the new /healthz staleness fields ------------------
def _healthz_payload(**staleness: Any) -> Dict[str, Any]:
return {
"ok": True,
"stream": {
"ok": True,
"state": "healthy",
"degradedForMs": 0,
"staleness": {
"lastInboundAt": "2026-07-28T00:00:00.000Z",
"silentForMs": 0,
"silenceThresholdMs": 600000,
"lastProbeAt": None,
"lastProbeOutcome": None,
"zombieSuspected": False,
**staleness,
},
},
}
@pytest.mark.asyncio
async def test_monitor_surfaces_zombie_suspected_without_fatal(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""zombieSuspected on /healthz is surfaced as a warning while the stream
is still 'ok' the fatal path stays owned by the degraded state (the
sidecar escalates to degraded -> exit 75 itself)."""
adapter = _make_adapter(monkeypatch)
adapter._inbound_running = True
adapter._sidecar_health_interval = 0.0
polls = 0
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
nonlocal polls
assert path == "/healthz"
polls += 1
if polls >= 2:
adapter._inbound_running = False
return _healthz_payload(
silentForMs=1_200_000,
lastProbeOutcome="alive",
zombieSuspected=True,
)
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
with caplog.at_level("WARNING"):
await adapter._monitor_sidecar_health()
assert adapter.has_fatal_error is False
assert any(
"suspected zombie stream" in rec.message for rec in caplog.records
)
# -- Adapter watchdog: inconclusive never counts toward respawn --------------
@pytest.mark.asyncio
async def test_inconclusive_probes_never_accumulate_toward_respawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Strict semantics end-to-end at the adapter: a 503/transport-error probe
(inconclusive) must not increment the failure counter the way the original
#45580 booleans did — only hung probes do."""
adapter = _make_adapter(monkeypatch)
class _Resp503:
status_code = 503
class _Client:
async def post(self, *args: Any, **kwargs: Any) -> Any:
return _Resp503()
adapter._http_client = _Client() # type: ignore[assignment]
# Many inconclusive probes in a row: mirror the watchdog's per-iteration
# bookkeeping (only "hung" increments) and assert no failures accrue.
for _ in range(10):
verdict = await adapter._probe_once()
assert verdict == "inconclusive"
if verdict == "hung":
adapter._probe_failures += 1
assert adapter._probe_failures == 0
@@ -0,0 +1,436 @@
"""Per-profile isolation of Discord/Telegram allow/deny gates (issue #72348).
Under ``gateway.multiplex_profiles: true`` every adapter must enforce ITS OWN
profile's allow/deny lists. The historical bugs:
1. First-writer-wins YAMLenv bridge: the first profile's
``_apply_yaml_config`` wrote ``DISCORD_ALLOWED_CHANNELS`` (etc.) into the
process-global ``os.environ``; later profiles' values were dropped.
2. Inbound gates read ``os.getenv`` directly, so every adapter enforced the
FIRST profile's channel/user/role allowlists.
3. Allow-all flags (``DISCORD_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS``)
read from process env: profile A opting in to open access opened profile B.
4. ``_resolve_allowed_usernames`` unconditionally rewrote
``os.environ["DISCORD_ALLOWED_USERS"]`` at runtime.
These tests build two adapter instances with different gate snapshots/extras
and assert each enforces only its own lists, order-independently.
"""
import os
import pytest
from gateway.config import Platform, PlatformConfig
from plugins.platforms.discord.adapter import DiscordAdapter, _GATE_ENV_KEYS
GATE_VARS = [
"DISCORD_ALLOWED_CHANNELS",
"DISCORD_IGNORED_CHANNELS",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOWED_ROLES",
"DISCORD_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_ALLOW_BOTS",
]
@pytest.fixture(autouse=True)
def _clean_gate_env(monkeypatch):
for var in GATE_VARS:
monkeypatch.delenv(var, raising=False)
yield
# monkeypatch.delenv on an ABSENT var records nothing, so env writes made
# during the test (e.g. _apply_yaml_config's legacy bridge) would leak
# into later test modules. Scrub explicitly.
for var in GATE_VARS:
os.environ.pop(var, None)
def _adapter(extra: dict | None = None) -> DiscordAdapter:
adapter = object.__new__(DiscordAdapter)
adapter.platform = Platform.DISCORD
adapter.config = PlatformConfig(enabled=True, token="x", extra=dict(extra or {}))
adapter._gate_env_snapshot = None
adapter._allowed_user_ids = set()
adapter._allowed_role_ids = set()
return adapter
def _snapshot(adapter: DiscordAdapter, values: dict) -> None:
"""Simulate the connect()-time per-profile snapshot."""
adapter._gate_env_snapshot = {key: values.get(key, "") for key in _GATE_ENV_KEYS}
class TestTwoAdapterChannelIsolation:
"""Two adapters with different allowed_channels enforce their OWN lists."""
def test_snapshots_isolate_allowed_channels(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"})
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
assert a._get_allowed_channels() == {"111"}
assert b._get_allowed_channels() == {"222"}
def test_order_independent(self):
# Reverse construction order — the winner must not change.
b = _adapter()
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
a = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"})
assert a._discord_channel_ids_allowed({"111"}) is True
assert a._discord_channel_ids_allowed({"222"}) is False
assert b._discord_channel_ids_allowed({"222"}) is True
assert b._discord_channel_ids_allowed({"111"}) is False
def test_extras_isolate_allowed_channels_without_snapshot(self):
"""Config-extra seeding isolates gates even before connect()."""
a = _adapter({"allowed_channels": "111"})
b = _adapter({"allowed_channels": "222"})
assert a._get_allowed_channels() == {"111"}
assert b._get_allowed_channels() == {"222"}
def test_process_env_does_not_leak_into_snapshotted_adapter(self, monkeypatch):
"""A first-writer process-global env value must not override a
snapshotted adapter's own (empty) gate."""
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "999")
b = _adapter({"allowed_channels": "222"})
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
assert b._get_allowed_channels() == {"222"}
def test_ignored_channels_isolated(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_IGNORED_CHANNELS": "311"})
_snapshot(b, {"DISCORD_IGNORED_CHANNELS": "322"})
assert a._get_ignored_channels() == {"311"}
assert b._get_ignored_channels() == {"322"}
class TestTwoAdapterUserRoleIsolation:
def test_allowed_users_isolated(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_USERS": "1001,<@1002>"})
_snapshot(b, {"DISCORD_ALLOWED_USERS": "2001"})
assert a._get_allowed_users() == {"1001", "1002"}
assert b._get_allowed_users() == {"2001"}
def test_allowed_roles_isolated(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_ROLES": "31,32"})
_snapshot(b, {"DISCORD_ALLOWED_ROLES": "41"})
assert a._get_allowed_roles() == {31, 32}
assert b._get_allowed_roles() == {41}
def test_is_allowed_user_enforces_own_list(self, monkeypatch):
# Pairing store must not interfere.
monkeypatch.setattr(
DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False
)
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_USERS": "1001"})
_snapshot(b, {"DISCORD_ALLOWED_USERS": "2001"})
a._allowed_user_ids = a._get_allowed_users()
b._allowed_user_ids = b._get_allowed_users()
assert a._is_allowed_user("1001") is True
assert a._is_allowed_user("2001") is False
assert b._is_allowed_user("2001") is True
assert b._is_allowed_user("1001") is False
class TestAllowAllFlagIsolation:
"""Profile A's allow-all flag must never authorize profile B (negative case)."""
def test_discord_allow_all_isolated(self, monkeypatch):
monkeypatch.setattr(
DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False
)
open_profile = _adapter()
closed_profile = _adapter()
_snapshot(open_profile, {"DISCORD_ALLOW_ALL_USERS": "true"})
_snapshot(closed_profile, {})
assert open_profile._is_allowed_user("555") is True
assert closed_profile._is_allowed_user("555") is False
def test_env_allow_all_does_not_open_snapshotted_adapter(self, monkeypatch):
"""First-writer env DISCORD_ALLOW_ALL_USERS=true (profile A) must not
open a snapshotted profile B."""
monkeypatch.setattr(
DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False
)
monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true")
b = _adapter()
_snapshot(b, {}) # profile B: no allow-all, no allowlists
assert b._discord_allow_all_users() is False
assert b._is_allowed_user("555") is False
def test_gateway_allow_all_isolated(self, monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
b = _adapter()
_snapshot(b, {})
assert b._gateway_allow_all_users() is False
class TestSlashGateIsolation:
"""Slash-command channel gates use per-adapter values too."""
def test_evaluate_slash_channel_gate_per_adapter(self, monkeypatch):
import types
discord_lib = pytest.importorskip(
"discord", reason="discord.py optional dep not installed"
)
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"})
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
def _keys(self, chan, parent):
return {str(getattr(chan, "id", ""))}
monkeypatch.setattr(
DiscordAdapter, "_discord_channel_keys_from_channel", _keys
)
monkeypatch.setattr(
DiscordAdapter, "_get_parent_channel_id", lambda self, c: None
)
chan = types.SimpleNamespace(id=111)
interaction = types.SimpleNamespace(
channel=chan, channel_id=111, user=types.SimpleNamespace(id=999, roles=[]),
)
# channel 111: allowed for A's gate...
allowed_a, reason_a = a._evaluate_slash_authorization(interaction)
# ...but B must reject it on ITS channel gate.
allowed_b, reason_b = b._evaluate_slash_authorization(interaction)
assert reason_a != "channel not in DISCORD_ALLOWED_CHANNELS"
assert allowed_b is False
assert reason_b == "channel not in DISCORD_ALLOWED_CHANNELS"
class TestUsernameResolutionEnvWrite:
"""_resolve_allowed_usernames must not clobber process env under multiplex."""
@pytest.mark.asyncio
async def test_no_env_write_when_multiplex_active(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "999")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
adapter = _adapter()
_snapshot(adapter, {"DISCORD_ALLOWED_USERS": "teknium"})
adapter._allowed_user_ids = {"teknium"}
member = type(
"M",
(),
{
"id": 12345,
"name": "teknium",
"display_name": "teknium",
"global_name": "teknium",
"discriminator": "0",
},
)()
guild = type(
"G", (), {"members": [member], "member_count": 1, "name": "g"},
)()
adapter._client = type("C", (), {"guilds": [guild]})()
await adapter._resolve_allowed_usernames()
assert adapter._allowed_user_ids == {"12345"}
# Snapshot updated for this adapter only.
assert adapter._gate_env_snapshot["DISCORD_ALLOWED_USERS"] == "12345"
# Process-global env untouched — other profiles unaffected.
assert os.environ["DISCORD_ALLOWED_USERS"] == "999"
@pytest.mark.asyncio
async def test_env_write_preserved_single_profile(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
adapter = _adapter()
adapter._allowed_user_ids = {"teknium"}
member = type(
"M",
(),
{
"id": 12345,
"name": "teknium",
"display_name": "teknium",
"global_name": "teknium",
"discriminator": "0",
},
)()
guild = type(
"G", (), {"members": [member], "member_count": 1, "name": "g"},
)()
adapter._client = type("C", (), {"guilds": [guild]})()
await adapter._resolve_allowed_usernames()
# Legacy single-profile behavior: env rewritten to resolved IDs.
assert os.environ["DISCORD_ALLOWED_USERS"] == "12345"
class TestYamlBridgeSeeding:
"""_apply_yaml_config seeds gates into extra and skips env writes when
loading a profile-scoped config under multiplex."""
def test_seeds_extra_and_bridges_env_single_profile(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.discord.adapter import _apply_yaml_config
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
seeded = _apply_yaml_config(
{},
{
"allowed_channels": ["111", "112"],
"ignored_channels": "333",
"allow_from": ["1001"],
"allowed_roles": [31],
"allow_all_users": False,
},
)
assert seeded["allowed_channels"] == "111,112"
assert seeded["ignored_channels"] == "333"
assert seeded["allow_from"] == "1001"
assert seeded["allowed_roles"] == "31"
assert seeded["allow_all_users"] == "false"
# Legacy env bridge preserved for single-profile deployments.
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "111,112"
def test_profile_scoped_load_skips_env_bridge(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.discord.adapter import _apply_yaml_config
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({"SOME": "scope"})
try:
seeded = _apply_yaml_config(
{}, {"allowed_channels": "222", "allow_from": "2001"},
)
finally:
secret_scope.reset_secret_scope(token)
# Gates still seeded per-adapter...
assert seeded["allowed_channels"] == "222"
assert seeded["allow_from"] == "2001"
# ...but process-global env stays clean: no cross-profile leak.
assert os.getenv("DISCORD_ALLOWED_CHANNELS") is None
assert os.getenv("DISCORD_ALLOWED_USERS") is None
def test_first_writer_env_does_not_mask_second_profile_extras(self, monkeypatch):
"""End-to-end shape of the original repro: profile A bridges env first;
profile B (scoped load) still gets ITS channels via extras."""
from agent import secret_scope
from plugins.platforms.discord.adapter import _apply_yaml_config
# Profile A: single first load (multiplex flag not yet set).
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
seeded_a = _apply_yaml_config({}, {"allowed_channels": "111"})
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "111"
# Profile B: scoped load under multiplex.
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
seeded_b = _apply_yaml_config({}, {"allowed_channels": "222"})
finally:
secret_scope.reset_secret_scope(token)
a = _adapter(seeded_a)
b = _adapter(seeded_b)
# B's snapshot taken inside its (empty-env) profile scope.
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
b._snapshot_gate_env()
finally:
secret_scope.reset_secret_scope(token)
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
a._snapshot_gate_env()
assert a._get_allowed_channels() == {"111"}
assert b._get_allowed_channels() == {"222"}
class TestTelegramGateIsolation:
"""Telegram mirror (reported by @yournetworkplug-ctrl in #72348)."""
def test_scoped_gate_env_prefers_profile_scope(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.telegram.adapter import _scoped_gate_env
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope(
{"TELEGRAM_ALLOWED_USERS": "222222222"}
)
try:
assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "222222222"
finally:
secret_scope.reset_secret_scope(token)
def test_scoped_gate_env_authoritative_scope_miss(self, monkeypatch):
"""Under multiplex, a scope WITHOUT the key must not fall through to
another profile's process-env value."""
from agent import secret_scope
from plugins.platforms.telegram.adapter import _scoped_gate_env
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == ""
finally:
secret_scope.reset_secret_scope(token)
def test_scoped_gate_env_single_profile_fallback(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.telegram.adapter import _scoped_gate_env
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "111111111"
def test_telegram_yaml_bridge_skipped_for_scoped_profile(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.telegram.adapter import _apply_yaml_config
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
monkeypatch.delenv("TELEGRAM_ALLOWED_USERS", raising=False)
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
extras = _apply_yaml_config(
{}, {"allowed_chats": ["-100200"], "allow_from": "222222222"},
)
finally:
secret_scope.reset_secret_scope(token)
# allowed_chats reaches PlatformConfig.extra via the shared-key loop
# in gateway/config.py (type-preserving); _apply_yaml_config must not
# write either gate into the process-global env for a scoped profile.
assert extras is None or "allowed_chats" not in extras
assert os.getenv("TELEGRAM_ALLOWED_CHATS") is None
assert os.getenv("TELEGRAM_ALLOWED_USERS") is None
+687
View File
@@ -0,0 +1,687 @@
"""
Streaming / push / anti-loop / task-store tests for the A2A plugin (v1.0).
Tests cover:
- v1.0 SSE StreamResponse format (member-name discrimination, no kind/final)
- message/stream and tasks/subscribe end-to-end against a live server
- Push notification HMAC signing
- Anti-loop ping-pong protection (TurnTracker + live rejection)
- Rate limiting (per-identity sliding window)
- Metrics collection (real latency)
- Task store (idempotent completion, watchers, orphan handling)
- Dynamic Agent Cards from the live tool registry
- Capability-based routing with fan-out (a2a_orchestrate)
- SSRF protection for push callback URLs
"""
from __future__ import annotations
import asyncio
import json
import socket
import time
import urllib.error
import urllib.request
import pytest
from plugins.platforms.a2a import protocol, security, tools
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _make_live_adapter(monkeypatch, reply_fn=None):
from plugins.platforms.a2a.adapter import A2AAdapter
from gateway.config import PlatformConfig
port = _free_port()
monkeypatch.setenv("A2A_PORT", str(port))
adapter = A2AAdapter(PlatformConfig(enabled=True))
async def fake_handle_message(event):
reply = "ECHO: " + event.text if reply_fn is None else reply_fn(event)
if reply is not None:
await adapter.send(event.source.chat_id, reply, metadata={"notify": True})
adapter.handle_message = fake_handle_message # type: ignore
adapter._message_handler = object()
return adapter, f"http://127.0.0.1:{port}"
def _post_sse(url, body):
"""POST a JSON-RPC request and return the parsed SSE stream as
(data_payloads, event_names). Unwraps the JSON-RPC envelope from
each data frame so callers see bare StreamResponse objects."""
req = urllib.request.Request(
url, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="POST",
)
with urllib.request.urlopen(req, timeout=15) as r:
raw = r.read().decode("utf-8")
payloads, events = [], []
for block in raw.split("\n\n"):
for line in block.splitlines():
if line.startswith("event: "):
events.append(line[len("event:"):].strip())
elif line.startswith("data: "):
data = line[len("data: "):].strip()
if data:
obj = json.loads(data)
# Unwrap JSON-RPC envelope: {"jsonrpc":"2.0","id":...,"result":{...}}
if isinstance(obj, dict) and "jsonrpc" in obj and "result" in obj:
payloads.append(obj["result"])
else:
payloads.append(obj)
# SSE comment lines (": done") are ignored — not data frames.
return payloads, events
def _post_json(url, body, headers=None):
req = urllib.request.Request(
url, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", **(headers or {})}, method="POST",
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
def _send_body(text, ctx="", method="message/send"):
return {
"jsonrpc": "2.0", "id": "1", "method": method,
"params": {"message": protocol.text_message(protocol.ROLE_USER, text, context_id=ctx)},
}
# ═════════════════════════════════════════════════════════════════════════════
# v1.0 SSE StreamResponse format
# ═════════════════════════════════════════════════════════════════════════════
class TestStreamResponseFormat:
def test_status_update_shape(self):
ev = protocol.status_update("task-1", "ctx-1", protocol.STATE_WORKING)
assert set(ev.keys()) == {"statusUpdate"}
su = ev["statusUpdate"]
assert su["taskId"] == "task-1"
assert su["contextId"] == "ctx-1"
assert su["status"]["state"] == "TASK_STATE_WORKING"
assert "kind" not in su and "final" not in su
def test_status_update_with_message(self):
ev = protocol.status_update("t", "c", protocol.STATE_INPUT_REQUIRED, "which one?")
msg = ev["statusUpdate"]["status"]["message"]
assert msg["role"] == "ROLE_AGENT"
assert protocol.extract_text(msg) == "which one?"
def test_artifact_update_shape(self):
ev = protocol.artifact_update("task-1", "ctx-1", "the result")
assert set(ev.keys()) == {"artifactUpdate"}
au = ev["artifactUpdate"]
assert au["taskId"] == "task-1"
part = au["artifact"]["parts"][0]
assert part == {"text": "the result", "mediaType": "text/plain"}
assert "kind" not in au and "final" not in au
def test_sse_data_framing(self):
chunk = protocol.sse_data({"statusUpdate": {"taskId": "t"}})
assert chunk.startswith("data: ")
assert chunk.endswith("\n\n")
# No event-name line: v1.0 discriminates by member presence.
assert "event:" not in chunk
def test_sse_data_jsonrpc_envelope(self):
"""A2A v1.0 §9.4: SSE frames must be JSON-RPC-wrapped when req_id is
provided. Bare StreamResponse (REST binding) breaks a2a-sdk clients."""
chunk = protocol.sse_data({"statusUpdate": {"taskId": "t"}}, req_id="42")
assert chunk.startswith("data: ")
obj = json.loads(chunk[len("data: "):].strip())
assert obj["jsonrpc"] == "2.0"
assert obj["id"] == "42"
assert "result" in obj
assert obj["result"]["statusUpdate"]["taskId"] == "t"
def test_sse_data_no_envelope_without_req_id(self):
"""Without req_id, sse_data falls back to bare payload for legacy callers."""
chunk = protocol.sse_data({"statusUpdate": {"taskId": "t"}})
obj = json.loads(chunk[len("data: "):].strip())
assert "jsonrpc" not in obj
assert obj["statusUpdate"]["taskId"] == "t"
def test_sse_done_marker(self):
"""v1.0 signals stream completion by closing the stream. The done
marker is an SSE comment (``: done``), not a parseable data frame
emitting ``data: {}`` breaks JSON-RPC clients that try to parse it."""
done = protocol.sse_done()
assert ": done" in done
assert "data:" not in done # no data frame for SDK to parse
assert done.endswith("\n\n")
@pytest.mark.integration
class TestStreamingEndToEnd:
def test_message_stream_v1_events(self, monkeypatch):
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
adapter, base = _make_live_adapter(monkeypatch)
async def run():
assert await adapter.connect() is True
payloads, events = await asyncio.to_thread(
_post_sse, base + "/", _send_body("stream me", method="message/stream"))
# Discrimination is by member name; every payload is a StreamResponse.
# v1.0 streaming begins with the current Task (or a direct Message),
# followed by status/artifact updates until terminal closure.
for p in payloads:
assert set(p.keys()) <= {"task", "message", "statusUpdate", "artifactUpdate"}
assert "kind" not in json.dumps(p)
assert "task" in payloads[0]
assert payloads[0]["task"]["status"]["state"] == "TASK_STATE_SUBMITTED"
states = [p["statusUpdate"]["status"]["state"]
for p in payloads if "statusUpdate" in p]
assert states[0] == "TASK_STATE_WORKING"
assert "TASK_STATE_WORKING" in states
assert states[-1] == "TASK_STATE_COMPLETED"
# No v0.3 'final' flag anywhere; closure is the terminal signal.
assert all("final" not in p.get("statusUpdate", {}) for p in payloads)
artifacts = [p["artifactUpdate"] for p in payloads if "artifactUpdate" in p]
assert len(artifacts) == 1
assert "ECHO:" in protocol.extract_text(artifacts[0]["artifact"])
assert events == [] # v1.0: stream closure is the terminal signal, no event frame
await adapter.disconnect()
asyncio.run(run())
def test_tasks_subscribe_replays_terminal_state(self, monkeypatch):
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
adapter, base = _make_live_adapter(monkeypatch)
async def run():
assert await adapter.connect() is True
resp = await asyncio.to_thread(_post_json, base + "/", _send_body("hello"))
task = resp["result"]
payloads, events = await asyncio.to_thread(_post_sse, base + "/", {
"jsonrpc": "2.0", "id": "2", "method": "tasks/subscribe",
"params": {"taskId": task["id"]},
})
states = [p["statusUpdate"]["status"]["state"]
for p in payloads if "statusUpdate" in p]
assert "TASK_STATE_COMPLETED" in states
artifacts = [p for p in payloads if "artifactUpdate" in p]
assert artifacts and "ECHO:" in protocol.extract_text(
artifacts[0]["artifactUpdate"]["artifact"])
assert events == [] # v1.0: stream closure is the terminal signal, no event frame
await adapter.disconnect()
asyncio.run(run())
def test_tasks_subscribe_unknown_task(self, monkeypatch):
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
adapter, base = _make_live_adapter(monkeypatch)
async def run():
assert await adapter.connect() is True
resp = await asyncio.to_thread(_post_json, base + "/", {
"jsonrpc": "2.0", "id": "2", "method": "tasks/subscribe",
"params": {"taskId": "ghost"},
})
assert resp["error"]["code"] == protocol.ERR_TASK_NOT_FOUND
await adapter.disconnect()
asyncio.run(run())
def test_agent_card_advertises_streaming(self):
card = protocol.build_agent_card(
name="test", url="http://localhost:9900/",
description="test", streaming=True, push_notifications=True,
)
assert card["capabilities"]["streaming"] is True
assert card["capabilities"]["pushNotifications"] is True
# ═════════════════════════════════════════════════════════════════════════════
# Push notification signing
# ═════════════════════════════════════════════════════════════════════════════
class TestPushSigning:
def test_sign_push_payload_deterministic(self, monkeypatch):
monkeypatch.setenv("A2A_PUSH_SECRET", "test-secret-123")
payload = {"statusUpdate": {"taskId": "task-1"}}
sig = security.sign_push_payload(payload)
assert sig
import hashlib
import hmac as hmac_mod
expected = hmac_mod.new(
b"test-secret-123",
json.dumps(payload, sort_keys=True, ensure_ascii=False).encode(),
hashlib.sha256,
).hexdigest()
assert sig == expected
def test_no_secret_means_unsigned(self, monkeypatch):
monkeypatch.delenv("A2A_PUSH_SECRET", raising=False)
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
assert security.sign_push_payload({"x": 1}) == ""
def test_falls_back_to_bearer_token(self, monkeypatch):
monkeypatch.delenv("A2A_PUSH_SECRET", raising=False)
monkeypatch.setenv("A2A_BEARER_TOKEN", "bearer-as-push-secret")
assert security.sign_push_payload({"x": 1})
# ═════════════════════════════════════════════════════════════════════════════
# Anti-loop ping-pong protection
# ═════════════════════════════════════════════════════════════════════════════
class TestAntiLoopProtection:
def test_track_turn_increments(self):
turns = protocol.TurnTracker()
assert turns.track("c1") == 1
assert turns.track("c1") == 2
assert turns.track("c1") == 3
assert turns.track("c2") == 1 # separate context
def test_reset_turns_clears(self):
turns = protocol.TurnTracker()
for _ in range(5):
turns.track("c1")
turns.reset("c1")
assert turns.track("c1") == 1
def test_max_pingpong_turns_default(self, monkeypatch):
monkeypatch.delenv("A2A_MAX_PINGPONG_TURNS", raising=False)
assert protocol.max_pingpong_turns() == 5
def test_max_pingpong_turns_env_override(self, monkeypatch):
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "10")
assert protocol.max_pingpong_turns() == 10
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "50")
assert protocol.max_pingpong_turns() == 20 # hard cap
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "0")
assert protocol.max_pingpong_turns() == 1 # min 1
@pytest.mark.integration
def test_loop_rejected_live(self, monkeypatch):
"""The turn past the limit is REJECTED (v1.0 state), not failed."""
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "2")
adapter, base = _make_live_adapter(monkeypatch)
async def run():
assert await adapter.connect() is True
states = []
for _ in range(3):
resp = await asyncio.to_thread(
_post_json, base + "/", _send_body("ping", ctx="ctx-pingpong"))
states.append(resp["result"]["status"]["state"])
assert states[0] == "TASK_STATE_COMPLETED"
assert states[1] == "TASK_STATE_COMPLETED"
assert states[2] == "TASK_STATE_REJECTED"
await adapter.disconnect()
asyncio.run(run())
# ═════════════════════════════════════════════════════════════════════════════
# Rate limiting
# ═════════════════════════════════════════════════════════════════════════════
class TestRateLimiting:
def test_allows_under_limit(self, monkeypatch):
monkeypatch.setenv("A2A_RATE_LIMIT", "10")
rl = protocol.RateLimiter()
for _ in range(10):
assert rl.allow("peer-1") is True
def test_blocks_over_limit(self, monkeypatch):
monkeypatch.setenv("A2A_RATE_LIMIT", "3")
rl = protocol.RateLimiter()
assert rl.allow("peer-2") is True
assert rl.allow("peer-2") is True
assert rl.allow("peer-2") is True
assert rl.allow("peer-2") is False # 4th blocked
def test_separate_per_identity(self, monkeypatch):
monkeypatch.setenv("A2A_RATE_LIMIT", "2")
rl = protocol.RateLimiter()
assert rl.allow("peer-a") is True
assert rl.allow("peer-a") is True
assert rl.allow("peer-a") is False
assert rl.allow("peer-b") is True # different bucket
assert rl.allow("peer-b") is True
@pytest.mark.integration
def test_rate_limit_live_returns_429(self, monkeypatch):
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
monkeypatch.setenv("A2A_RATE_LIMIT", "2")
adapter, base = _make_live_adapter(monkeypatch)
async def run():
assert await adapter.connect() is True
def _burst():
codes = []
for _ in range(3):
try:
_post_json(base + "/", _send_body("hi"))
codes.append(200)
except urllib.error.HTTPError as e:
codes.append(e.code)
err = json.loads(e.read().decode())
assert err["error"]["code"] == protocol.ERR_RATE_LIMITED
return codes
codes = await asyncio.to_thread(_burst)
assert codes[:2] == [200, 200]
assert codes[2] == 429
await adapter.disconnect()
asyncio.run(run())
# ═════════════════════════════════════════════════════════════════════════════
# Metrics
# ═════════════════════════════════════════════════════════════════════════════
class TestMetrics:
def test_metrics_snapshot_has_fields(self):
m = protocol.metrics.snapshot()
for field in ("uptime_seconds", "inbound_total", "outbound_total",
"streams_started", "push_sent", "push_failed",
"tasks_completed", "tasks_failed", "anti_loop_triggers",
"rate_limit_triggers", "avg_latency_ms"):
assert field in m
def test_record_latency_updates_average(self):
m = protocol.Metrics()
m.record_latency(0.1)
m.record_latency(0.3)
assert 0.19 <= m.avg_latency() <= 0.21
@pytest.mark.integration
def test_latency_is_actually_recorded_live(self, monkeypatch):
"""The avg latency metric must be fed by real elapsed time, not a
hardcoded 0."""
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
def slow_reply(event):
time.sleep(0.05)
return "done"
adapter, base = _make_live_adapter(monkeypatch, reply_fn=slow_reply)
async def run():
assert await adapter.connect() is True
before = len(protocol.metrics._latencies)
await asyncio.to_thread(_post_json, base + "/", _send_body("time me"))
new = list(protocol.metrics._latencies)[before:]
assert new and new[-1] >= 0.05
await adapter.disconnect()
asyncio.run(run())
# ═════════════════════════════════════════════════════════════════════════════
# Task store
# ═════════════════════════════════════════════════════════════════════════════
class TestTaskStore:
def test_create_and_get(self):
store = protocol.TaskStore()
store.create("t1", "c1", "peer-1")
rec = store.get("t1")
assert rec["state"] == protocol.STATE_SUBMITTED
assert rec["context_id"] == "c1"
assert rec["peer"] == "peer-1"
def test_complete_keeps_task_queryable(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
store.complete("t1", protocol.STATE_COMPLETED, "the reply")
rec = store.get("t1")
assert rec is not None
assert rec["state"] == protocol.STATE_COMPLETED
assert rec["reply"] == "the reply"
def test_complete_is_idempotent(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
assert store.complete("t1", protocol.STATE_COMPLETED, "first") is not None
# Second terminal transition is refused (prevents double-counting).
assert store.complete("t1", protocol.STATE_FAILED, "second") is None
assert store.get("t1")["state"] == protocol.STATE_COMPLETED
assert store.complete("ghost", protocol.STATE_FAILED) is None
def test_watch_resolves_on_complete(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
fut = store.watch("t1")
assert not fut.done()
store.complete("t1", protocol.STATE_COMPLETED, "answer")
assert fut.result(timeout=0) == (protocol.STATE_COMPLETED, "answer")
def test_watch_terminal_resolves_immediately(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
store.complete("t1", protocol.STATE_FAILED, "err")
fut = store.watch("t1")
assert fut.result(timeout=0) == (protocol.STATE_FAILED, "err")
assert store.watch("ghost") is None
def test_fail_orphans(self):
store = protocol.TaskStore()
store.create("t-old", "c1", "p")
store.create("t-new", "c1", "p")
store._tasks["t-old"]["created_at"] = time.time() - 600
failed = store.fail_orphans(timeout_seconds=300)
assert failed == ["t-old"]
assert store.get("t-old")["state"] == protocol.STATE_FAILED
assert store.get("t-new")["state"] == protocol.STATE_SUBMITTED
# Second sweep does nothing (already terminal).
assert store.fail_orphans(timeout_seconds=300) == []
def test_list_newest_first_with_filters(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
store.create("t2", "c2", "p")
store.create("t3", "c1", "p")
store.complete("t1", protocol.STATE_COMPLETED)
recs, _ = store.list(context_id="c1")
assert [r["task_id"] for r in recs] == ["t3", "t1"]
recs, _ = store.list(state=protocol.STATE_SUBMITTED)
assert {r["task_id"] for r in recs} == {"t2", "t3"}
def test_push_config_lifecycle(self):
store = protocol.TaskStore()
store.create("t1", "c1", "p")
cfg = store.set_push_config("t1", "https://example.com/hook")
assert cfg["configId"].startswith("cfg-")
assert cfg["createdAt"]
assert store.pop_push_url("t1") == "https://example.com/hook"
assert store.pop_push_url("t1") == "" # consumed
assert store.set_push_config("ghost", "https://x/") is None
# ═════════════════════════════════════════════════════════════════════════════
# Dynamic Agent Cards
# ═════════════════════════════════════════════════════════════════════════════
class TestDynamicAgentCards:
def test_skills_reflect_live_tool_registry(self, monkeypatch):
"""The Agent Card is built from the real tool registry at serve time."""
from tools.registry import registry
from gateway.config import PlatformConfig
from plugins.platforms.a2a.adapter import A2AAdapter
monkeypatch.setattr(registry, "get_registered_toolset_names",
lambda: ["webz", "termz"])
monkeypatch.setattr(registry, "get_tool_names_for_toolset",
lambda ts: {"webz": ["web_search"], "termz": ["terminal"]}[ts])
adapter = A2AAdapter(PlatformConfig(enabled=True))
card = adapter._build_card()
by_name = {s["name"]: s for s in card["skills"]}
assert set(by_name) == {"webz", "termz"}
assert "web_search" in by_name["webz"]["tags"]
def test_advertised_toolsets_restrict_card(self, monkeypatch):
from tools.registry import registry
from gateway.config import PlatformConfig
from plugins.platforms.a2a.adapter import A2AAdapter
monkeypatch.setattr(registry, "get_registered_toolset_names",
lambda: ["webz", "termz", "secretz"])
monkeypatch.setattr(registry, "get_tool_names_for_toolset", lambda ts: [])
monkeypatch.setenv("A2A_ADVERTISED_TOOLSETS", "webz")
adapter = A2AAdapter(PlatformConfig(enabled=True))
card = adapter._build_card()
assert [s["name"] for s in card["skills"]] == ["webz"]
# ═════════════════════════════════════════════════════════════════════════════
# Capability-based routing (a2a_orchestrate)
# ═════════════════════════════════════════════════════════════════════════════
_TWO_PEERS = {
"a2a_agents": {
"researcher": {"url": "http://localhost:9991", "capabilities": ["research"]},
"coder": {"url": "http://localhost:9992", "capabilities": ["code"]},
"generalist": {"url": "http://localhost:9993", "capabilities": ["research", "code"]},
}
}
class TestA2AOrchestrate:
def test_requires_capability_and_message(self):
assert "capability" in tools.a2a_orchestrate({"message": "do something"})
assert "message" in tools.a2a_orchestrate({"capability": "research"})
def test_no_matching_peers(self, monkeypatch):
monkeypatch.setattr(tools, "_load_config", lambda: {})
result = tools.a2a_orchestrate({"capability": "research", "message": "search X"})
assert "no configured peers" in result
def test_match_peers_by_capability(self, monkeypatch):
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
matches = tools._match_peers_by_capability("research")
assert {m[0] for m in matches} == {"researcher", "generalist"}
assert len(tools._match_peers_by_capability("*")) == 3
def test_all_mode_returns_every_reply(self, monkeypatch):
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
monkeypatch.setattr(tools, "_call_peer_sync",
lambda name, entry, msg, ctx="": (name, f"reply from {name}"))
out = tools.a2a_orchestrate({"capability": "research", "message": "go"})
assert "reply from researcher" in out
assert "reply from generalist" in out
def test_best_mode_picks_longest_success(self, monkeypatch):
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
replies = {
"researcher": "short",
"generalist": "a much longer and more detailed reply",
}
monkeypatch.setattr(tools, "_call_peer_sync",
lambda name, entry, msg, ctx="": (name, replies[name]))
out = tools.a2a_orchestrate({"capability": "research", "message": "go", "mode": "best"})
assert out.startswith("[best: generalist]")
def test_best_mode_ignores_error_replies(self, monkeypatch):
"""A long error must not beat a short success (old max() heuristic bug)."""
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
replies = {
"researcher": "ok",
"generalist": "Error: " + "x" * 500,
}
monkeypatch.setattr(tools, "_call_peer_sync",
lambda name, entry, msg, ctx="": (name, replies[name]))
out = tools.a2a_orchestrate({"capability": "research", "message": "go", "mode": "best"})
assert out.startswith("[best: researcher]")
assert "ok" in out
def test_best_mode_all_errors_reports_failure(self, monkeypatch):
"""All-error edge: report the failures instead of returning one error
with a misleading [best: ...] header."""
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
monkeypatch.setattr(tools, "_call_peer_sync",
lambda name, entry, msg, ctx="": (name, "Error: connection refused"))
out = tools.a2a_orchestrate({"capability": "research", "message": "go", "mode": "best"})
assert out.startswith("All peers failed:")
assert "[best:" not in out
def test_first_mode_all_errors_reports_failure(self, monkeypatch):
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
monkeypatch.setattr(tools, "_call_peer_sync",
lambda name, entry, msg, ctx="": (name, "Error: nope"))
out = tools.a2a_orchestrate({"capability": "code", "message": "go", "mode": "first"})
assert out.startswith("All peers failed:")
def test_first_mode_returns_a_success(self, monkeypatch):
monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
monkeypatch.setattr(tools, "_call_peer_sync",
lambda name, entry, msg, ctx="": (name, f"win {name}"))
out = tools.a2a_orchestrate({"capability": "code", "message": "go", "mode": "first"})
assert out.startswith("[first: ")
assert "win" in out
# ═════════════════════════════════════════════════════════════════════════════
# SSRF protection for push callbacks
# ═════════════════════════════════════════════════════════════════════════════
class TestSSRFProtection:
def test_safe_public_urls_allowed(self):
assert security.is_safe_callback_url("https://example.com/webhook") is True
assert security.is_safe_callback_url("http://example.com/webhook") is True
def test_localhost_blocked_in_remote_mode(self, monkeypatch):
monkeypatch.setenv("A2A_BEARER_TOKEN", "tok") # remote mode
assert security.is_safe_callback_url("http://127.0.0.1:8080/hook") is False
assert security.is_safe_callback_url("http://localhost:8080/hook") is False
def test_localhost_allowed_in_local_mode(self, monkeypatch):
monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
assert security.is_safe_callback_url("http://127.0.0.1:8080/hook") is True
assert security.is_safe_callback_url("http://localhost:8080/hook") is True
def test_aws_metadata_blocked(self, monkeypatch):
monkeypatch.setenv("A2A_BEARER_TOKEN", "tok")
assert security.is_safe_callback_url("http://169.254.169.254/latest/meta-data/") is False
def test_private_ranges_blocked(self, monkeypatch):
monkeypatch.setenv("A2A_BEARER_TOKEN", "tok")
assert security.is_safe_callback_url("http://10.0.0.1/hook") is False
assert security.is_safe_callback_url("http://192.168.1.1/hook") is False
assert security.is_safe_callback_url("http://172.16.0.1/hook") is False
def test_non_http_schemes_blocked(self):
assert security.is_safe_callback_url("file:///etc/passwd") is False
assert security.is_safe_callback_url("ftp://example.com/file") is False
def test_empty_url_blocked(self):
assert security.is_safe_callback_url("") is False
assert security.is_safe_callback_url(None) is False
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
"""Regression tests for A2A client-tool schema registration."""
from __future__ import annotations
import json
from plugins.platforms.a2a import tools as a2a_tools
from tools import tool_search
from tools.registry import ToolRegistry
def test_a2a_call_schema_round_trips_through_tool_describe(monkeypatch):
registry = ToolRegistry()
# The client tools are config-gated now (test_a2a_tools_gate.py):
# open the gate the way a real install would — configure a peer.
monkeypatch.setattr(
a2a_tools,
"_load_config",
lambda: {"a2a_agents": {"peer": {"url": "http://localhost:9999"}}},
)
class _Context:
def register_tool(self, name, toolset, schema, handler, **kwargs):
registry.register(
name=name,
toolset=toolset,
schema=schema,
handler=handler,
**kwargs,
)
a2a_tools.register_tools(_Context())
definitions = registry.get_definitions({"a2a_call"})
monkeypatch.setattr(
tool_search,
"is_deferrable_tool_name",
# #97979 added the defer_tools positional (curated-set override).
lambda name, defer_tools=None: name == "a2a_call",
)
described = json.loads(
tool_search.dispatch_tool_describe(
{"names": ["a2a_call"]},
current_tool_defs=definitions,
)
)["tools"]["a2a_call"]
assert described["description"]
assert described["parameters"]["required"] == ["agent", "message"]
assert set(described["parameters"]["properties"]) == {
"agent",
"message",
"context_id",
}
+65
View File
@@ -0,0 +1,65 @@
"""a2a client tools config gate (#95681, maintainer-directed).
The 5 outbound a2a_* tools registered unconditionally every session on
every install paid ~561 tok/call for a toolset whose only possible output
without config is "no peers configured". A2A is NOT the Bot Mode
mechanism (bots talk over gateway RPCs); it is opt-in foreign-agent
plumbing. Gate: serve only when a2a_agents is non-empty, the inbound
platform is enabled, or A2A_PORT is set. Fail closed.
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import plugins.platforms.a2a.tools as a2at
class TestA2AToolsGate(unittest.TestCase):
def setUp(self):
os.environ.pop("A2A_PORT", None)
def _avail(self, cfg):
with patch.object(a2at, "_load_config", return_value=cfg):
return a2at._a2a_tools_available()
def test_unconfigured_install_serves_nothing(self):
self.assertFalse(self._avail({}))
self.assertFalse(self._avail({"a2a_agents": {}}))
def test_peers_configured_serves(self):
self.assertTrue(self._avail({"a2a_agents": {"r": {"url": "http://x"}}}))
def test_inbound_platform_enabled_serves(self):
self.assertTrue(self._avail({"platforms": {"a2a": {"enabled": True}}}))
def test_a2a_port_env_serves(self):
os.environ["A2A_PORT"] = "9999"
try:
self.assertTrue(self._avail({}))
finally:
os.environ.pop("A2A_PORT", None)
def test_config_crash_fails_closed(self):
with patch.object(a2at, "_load_config", side_effect=RuntimeError("boom")):
self.assertFalse(a2at._a2a_tools_available())
def test_all_five_tools_carry_the_gate(self):
"""Every a2a_* registration must pass the check_fn — a sixth tool
added without it would silently reopen the hole."""
seen = {}
class Ctx:
def register_tool(self, name, **kw):
seen[name] = kw.get("check_fn")
a2at.register_tools(Ctx())
self.assertEqual(len(seen), 5, sorted(seen))
for name, fn in seen.items():
self.assertIs(fn, a2at._a2a_tools_available, name)
if __name__ == "__main__":
unittest.main()
+303
View File
@@ -0,0 +1,303 @@
"""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_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_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"
)
+239
View File
@@ -0,0 +1,239 @@
"""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
# -- 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_register_job_arms_only_the_created_job(chronos):
prov, fake = chronos
job = {"id": "created", "next_run_at": "2026-06-18T12:00:00+00:00"}
prov.register_job(job)
assert [p["job_id"] for p in fake.provisions] == ["created"]
def test_register_job_propagates_provision_failure(chronos):
prov, fake = chronos
def fail_provision(**kwargs):
raise RuntimeError("provision rejected")
fake.provision = fail_provision
with pytest.raises(RuntimeError, match="provision rejected"):
prov.register_job(
{"id": "created", "next_run_at": "2026-06-18T12:00:00+00:00"}
)
# -- 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 == []
# -- fire_due re-arm ----------------------------------------------------------
def test_fire_due_rearms_next_oneshot(chronos, monkeypatch):
prov, fake = chronos
# Keep the two-phase provider flow intact while stubbing durable admission
# and the shared runner body.
monkeypatch.setattr(
"cron.scheduler_provider.CronScheduler.claim_fire",
lambda self, jid, **kw: {"id": jid, "execution_id": "exec-1"},
)
monkeypatch.setattr(
"cron.scheduler_provider.CronScheduler.fire_claimed",
lambda self, job, **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_rearms_after_claimed_job_failure(chronos, monkeypatch):
"""A claimed attempt is consumed even when the job pipeline reports failure."""
prov, fake = chronos
claimed = {"id": "j1", "fire_claim": {"by": "owner-1"}}
persisted = {
"id": "j1",
"enabled": True,
"next_run_at": "2026-06-18T12:05:00+00:00",
}
monkeypatch.setattr("cron.jobs.claim_job_for_fire", lambda jid, **kw: claimed)
monkeypatch.setattr(
"cron.executions.create_execution",
lambda jid, source: {"id": "exec-1"},
)
monkeypatch.setattr("cron.scheduler.run_one_job", lambda *args, **kwargs: False)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: persisted)
assert prov.fire_due("j1") is True
assert [provision["job_id"] for provision in fake.provisions] == ["j1"]
def test_fire_due_forwards_manual_force_to_claim(chronos, monkeypatch):
"""A manual force fire must reach the store claim as force=True."""
prov, _fake = chronos
seen = []
monkeypatch.setattr(
"cron.jobs.claim_job_for_fire",
lambda jid, **kw: seen.append(kw) or False,
)
monkeypatch.setattr(
"cron.executions.create_execution",
lambda jid, source: {"id": "exec-1"},
)
assert prov.fire_due("j1", force=True) is False
assert seen == [{"return_job": True, "force": True}]
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 == []
# -- provider capability classification ----------------------------------------
def test_chronos_is_split_fire_capable(chronos):
"""Regression: Chronos must be classified as a split-aware provider so the
fire webhook uses durable claim admission (not the legacy fire_due path).
Chronos deliberately has NO fire_due override its re-arm logic lives in
fire_claimed, which the split path invokes."""
from cron.scheduler_provider import (
provider_supports_fire_cancel,
provider_supports_force_fire,
provider_supports_split_fire,
)
prov, _fake = chronos
assert provider_supports_split_fire(prov) is True
assert provider_supports_force_fire(prov) is True
assert provider_supports_fire_cancel(prov) is True
def test_fire_claimed_no_rearm_when_run_failed(chronos, monkeypatch):
prov, fake = chronos
monkeypatch.setattr(
"cron.scheduler_provider.CronScheduler.fire_claimed",
lambda self, job, **kw: False,
)
assert prov.fire_claimed({"id": "j1"}) is False
assert fake.provisions == []
def test_fire_claimed_no_rearm_when_job_gone(chronos, monkeypatch):
prov, fake = chronos
monkeypatch.setattr(
"cron.scheduler_provider.CronScheduler.fire_claimed",
lambda self, job, **kw: True,
)
monkeypatch.setattr("cron.jobs.get_job", lambda jid: None)
assert prov.fire_claimed({"id": "j1"}) is True
assert fake.provisions == []
+193
View File
@@ -0,0 +1,193 @@
"""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_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_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 import verify as verify_mod
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, **kwargs):
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)
# Isolate from the process-wide client cache (other tests may have populated it).
monkeypatch.setattr(verify_mod, "_JWK_CLIENTS", {})
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_jwks_client_sends_explicit_http_headers(monkeypatch):
"""Constructor-contract regression: the JWKS fetch must send an explicit
Accept + User-Agent so it isn't blocked by the NAS portal WAF (same fix as
the dashboard-auth nous/self_hosted providers)."""
from plugins.cron_providers.chronos import verify as verify_mod
captured = {}
class FakeJWKClient:
def __init__(self, url, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
monkeypatch.setattr("jwt.PyJWKClient", FakeJWKClient)
monkeypatch.setattr(verify_mod, "_JWK_CLIENTS", {})
url = "https://portal.nousresearch.com/.well-known/jwks.json"
verify_mod._get_jwk_client(url)
assert captured["url"] == url
assert captured["kwargs"].get("headers") == {
"Accept": "application/json",
"User-Agent": "HermesAgent/1.0",
}
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,40 @@
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()
+421
View File
@@ -0,0 +1,421 @@
"""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
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_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_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_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
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_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_unknown_subcommand(self, _isolate_env):
pi = _load_plugin_init()
out = pi._handle_slash("foobar")
assert "Unknown subcommand" 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_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
+164
View File
@@ -0,0 +1,164 @@
"""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()
# ---------------------------------------------------------------------------
# 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"
)
# ---------------------------------------------------------------------------
# Windows / unsupported
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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_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
+187
View File
@@ -0,0 +1,187 @@
"""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}
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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 _run(coro):
return asyncio.new_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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
+300
View File
@@ -0,0 +1,300 @@
"""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_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_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_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"]
# ---------------------------------------------------------------------------
# _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()
# ---------------------------------------------------------------------------
# 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": []}
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# v3: NodeClient routing from tool handlers
# ---------------------------------------------------------------------------
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"
# ---------------------------------------------------------------------------
# v2.1: new _BotState fields + status dict shape
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# hermes meet install CLI
# ---------------------------------------------------------------------------
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
+227
View File
@@ -0,0 +1,227 @@
"""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_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
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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() == ""
@@ -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,76 @@
"""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 _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
@@ -0,0 +1,205 @@
"""Storage-size regression tests for holographic HRR vectors."""
from __future__ import annotations
import pytest
np = pytest.importorskip("numpy")
from plugins.memory.holographic import holographic as hrr
from plugins.memory.holographic.retrieval import FactRetriever
from plugins.memory.holographic.store import MemoryStore
pytestmark = pytest.mark.skipif(
not hrr._HAS_NUMPY,
reason="holographic vector storage requires numpy",
)
def _float32_blob_size(dim: int) -> int:
return len(hrr._FLOAT32_BLOB_PREFIX) + dim * np.dtype(np.float32).itemsize
def test_phases_to_bytes_stores_float32_and_round_trips_with_dim() -> None:
dim = 1024
phases = hrr.encode_atom("storage-size-regression", dim=dim)
blob = hrr.phases_to_bytes(phases)
assert len(blob) == _float32_blob_size(dim)
restored = hrr.bytes_to_phases(blob, dim=dim)
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=1e-6)
def test_phases_to_bytes_round_trips_without_dim() -> None:
dim = 1024
phases = hrr.encode_atom("dimensionless-round-trip", dim=dim)
restored = hrr.bytes_to_phases(hrr.phases_to_bytes(phases))
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=1e-6)
def test_phases_to_bytes_round_trips_ambiguous_small_dims_without_dim() -> None:
dim = 2
phases = hrr.encode_atom("ambiguous-small-dimension", dim=dim)
restored = hrr.bytes_to_phases(hrr.phases_to_bytes(phases))
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=1e-6)
def test_bytes_to_phases_rejects_malformed_float32_blobs() -> None:
phases = hrr.encode_atom("malformed-float32-blob", dim=2)
blob = hrr.phases_to_bytes(phases)
with pytest.raises(ValueError, match="expected .* for dim=3"):
hrr.bytes_to_phases(blob, dim=3)
with pytest.raises(ValueError, match="invalid payload byte length"):
hrr.bytes_to_phases(hrr._FLOAT32_BLOB_PREFIX + b"x")
def test_bytes_to_phases_reads_legacy_float64_blobs_with_and_without_dim() -> None:
dim = 1024
phases = hrr.encode_atom("legacy-float64-regression", dim=dim)
legacy_blob = phases.astype(np.float64, copy=False).tobytes()
assert len(legacy_blob) == dim * np.dtype(np.float64).itemsize
restored_with_dim = hrr.bytes_to_phases(legacy_blob, dim=dim)
restored_without_dim = hrr.bytes_to_phases(legacy_blob)
assert restored_with_dim.shape == (dim,)
assert restored_without_dim.shape == (dim,)
np.testing.assert_allclose(restored_with_dim, phases, rtol=0, atol=0)
np.testing.assert_allclose(restored_without_dim, phases, rtol=0, atol=0)
def test_bytes_to_phases_prefers_dim_matched_legacy_float64_on_prefix_collision() -> None:
dim = 4
legacy_blob = hrr._FLOAT32_BLOB_PREFIX + b"\0" * (
dim * np.dtype(np.float64).itemsize - len(hrr._FLOAT32_BLOB_PREFIX)
)
restored = hrr.bytes_to_phases(legacy_blob, dim=dim)
assert restored.shape == (dim,)
np.testing.assert_array_equal(
restored,
np.frombuffer(legacy_blob, dtype=np.float64).copy(),
)
def test_dim1_phases_to_bytes_writes_legacy_float64() -> None:
"""At dim=1 the float32 prefixed blob (8 B) collides with raw float64
(8 B), so phases_to_bytes must fall back to raw float64."""
dim = 1
phases = hrr.encode_atom("dim-one-ambiguity", dim=dim)
blob = hrr.phases_to_bytes(phases, dim=dim)
assert len(blob) == dim * np.dtype(np.float64).itemsize # 8 bytes, no prefix
assert not blob.startswith(hrr._FLOAT32_BLOB_PREFIX)
def test_dim1_round_trip_with_dim() -> None:
"""Round-trip at dim=1 must work via the legacy float64 path."""
dim = 1
phases = hrr.encode_atom("dim-one-round-trip", dim=dim)
restored = hrr.bytes_to_phases(hrr.phases_to_bytes(phases, dim=dim), dim=dim)
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, phases, rtol=0, atol=0)
def test_dim1_legacy_blob_starting_with_prefix_decodes_as_float64() -> None:
"""A legacy float64 blob at dim=1 that happens to start with HRR1 must
decode as float64, not be misread as a prefixed float32 blob."""
dim = 1
phases = hrr.encode_atom("prefix-collision-dim-one", dim=dim)
legacy_blob = phases.astype(np.float64).tobytes()
# Force the blob to start with HRR1 prefix bytes
collision_blob = hrr._FLOAT32_BLOB_PREFIX + legacy_blob[len(hrr._FLOAT32_BLOB_PREFIX):]
assert len(collision_blob) == dim * np.dtype(np.float64).itemsize
restored = hrr.bytes_to_phases(collision_blob, dim=dim)
assert restored.shape == (dim,)
np.testing.assert_allclose(restored, np.frombuffer(collision_blob, dtype=np.float64).copy(), rtol=0, atol=0)
def test_memory_store_reads_legacy_float64_vectors(tmp_path) -> None:
dim = 64
db_path = tmp_path / "legacy_memory_store.db"
with MemoryStore(db_path=db_path, hrr_dim=dim) as store:
fact_id = store.add_fact(
'Bob Stone keeps "legacy HRR vectors" searchable.',
category="compat",
tags="legacy storage",
)
fact_blob = store._conn.execute(
"SELECT hrr_vector FROM facts WHERE fact_id = ?",
(fact_id,),
).fetchone()["hrr_vector"]
bank_blob = store._conn.execute(
"SELECT vector FROM memory_banks WHERE bank_name = ?",
("cat:compat",),
).fetchone()["vector"]
legacy_fact_blob = hrr.bytes_to_phases(fact_blob, dim=dim).astype(np.float64).tobytes()
legacy_bank_blob = hrr.bytes_to_phases(bank_blob, dim=dim).astype(np.float64).tobytes()
store._conn.execute(
"UPDATE facts SET hrr_vector = ? WHERE fact_id = ?",
(legacy_fact_blob, fact_id),
)
store._conn.execute(
"UPDATE memory_banks SET vector = ? WHERE bank_name = ?",
(legacy_bank_blob, "cat:compat"),
)
store._conn.commit()
assert len(legacy_fact_blob) == dim * np.dtype(np.float64).itemsize
assert len(legacy_bank_blob) == dim * np.dtype(np.float64).itemsize
retriever = FactRetriever(store, hrr_dim=dim)
results = retriever.search("legacy HRR vectors", category="compat", limit=1)
assert results
assert results[0]["fact_id"] == fact_id
def test_memory_store_persists_fact_and_bank_vectors_as_float32(tmp_path) -> None:
dim = 64
db_path = tmp_path / "memory_store.db"
with MemoryStore(db_path=db_path, hrr_dim=dim) as store:
fact_id = store.add_fact(
'Alice Smith stores "compact HRR vectors" for Python tests.',
category="perf",
tags="hrr storage",
)
fact_blob = store._conn.execute(
"SELECT hrr_vector FROM facts WHERE fact_id = ?",
(fact_id,),
).fetchone()["hrr_vector"]
bank_blob = store._conn.execute(
"SELECT vector FROM memory_banks WHERE bank_name = ?",
("cat:perf",),
).fetchone()["vector"]
assert len(fact_blob) == _float32_blob_size(dim)
assert len(bank_blob) == _float32_blob_size(dim)
retriever = FactRetriever(store, hrr_dim=dim)
results = retriever.search("compact HRR vectors", category="perf", limit=1)
assert results
assert results[0]["fact_id"] == fact_id

Some files were not shown because too many files have changed in this diff Show More