Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 YAML→env 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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 == []
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user