chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""Behavior-parity check for the browser-provider plugin migration (#25214).
|
||||
|
||||
Spawns one subprocess per (version, scenario) cell — pinned to either
|
||||
origin/main (legacy in-tree providers + class-instantiation lookup) or
|
||||
this PR's worktree (plugin-based registry) via `sys.path[0]`. Each
|
||||
subprocess clears all browser-related env vars + writes a config.yaml,
|
||||
loads `tools.browser_tool._get_cloud_provider()`, and emits a reduced
|
||||
"shape tuple" {is_local, provider_name, is_available} as JSON.
|
||||
|
||||
The parent process diffs the shapes per scenario. A diff means the
|
||||
migration introduced an observable behaviour change vs origin/main —
|
||||
which would be a real regression for users on the existing config keys.
|
||||
|
||||
Run from the PR worktree:
|
||||
|
||||
cd ~/.hermes/hermes-agent/.worktrees/browser-providers-plugin
|
||||
python tests/plugins/browser/check_parity_vs_main.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
# Pin one path to current main, one to the PR worktree.
|
||||
# ``REPO_ROOT`` is ``.../.worktrees/browser-providers-plugin``; the main
|
||||
# checkout lives two levels up at ``~/.hermes/hermes-agent``.
|
||||
MAIN_DIR = REPO_ROOT.parent.parent # ~/.hermes/hermes-agent
|
||||
PR_DIR = REPO_ROOT # the worktree we're in
|
||||
assert (MAIN_DIR / "tools" / "browser_tool.py").exists(), (
|
||||
f"MAIN_DIR={MAIN_DIR} doesn't look like a hermes-agent checkout"
|
||||
)
|
||||
assert (PR_DIR / "tools" / "browser_tool.py").exists(), (
|
||||
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
|
||||
)
|
||||
|
||||
|
||||
# Reduced shape comparison — exact instance addresses obviously differ
|
||||
# between subprocesses, so we compare the parts that matter for users.
|
||||
SUBPROCESS_SCRIPT = r"""
|
||||
import json, os, sys, tempfile
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
# Isolated HERMES_HOME for the config write.
|
||||
home = tempfile.mkdtemp()
|
||||
os.environ["HERMES_HOME"] = home
|
||||
|
||||
# Clear every browser-related env var so is_available() is deterministic.
|
||||
for k in (
|
||||
"BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSERBASE_BASE_URL",
|
||||
"BROWSER_USE_API_KEY", "BROWSER_USE_GATEWAY_URL",
|
||||
"FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_BROWSER_TTL",
|
||||
"TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
|
||||
):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
# Apply per-scenario env (passed as JSON via argv[2]).
|
||||
scenario_env = json.loads(sys.argv[2])
|
||||
os.environ.update(scenario_env)
|
||||
|
||||
# Apply per-scenario config (passed as YAML body via argv[3]).
|
||||
config_yaml = sys.argv[3]
|
||||
config_path = os.path.join(home, "config.yaml")
|
||||
with open(config_path, "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
# Fresh import — must not have any browser modules cached.
|
||||
for name in list(sys.modules):
|
||||
if name.startswith("tools.") or name.startswith("agent.") or name.startswith("plugins."):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
from tools.browser_tool import _get_cloud_provider, _is_local_mode
|
||||
|
||||
provider = _get_cloud_provider()
|
||||
|
||||
# Pull the human-readable backend name via the API that exists on BOTH
|
||||
# legacy (origin/main: CloudBrowserProvider.provider_name()) and the new
|
||||
# ABC (BrowserProvider exposes provider_name() as a backward-compat alias
|
||||
# returning display_name). Both shapes resolve to the same string —
|
||||
# 'Browserbase' / 'Browser Use' / 'Firecrawl' — so we can compare safely.
|
||||
provider_name = None
|
||||
is_available = None
|
||||
if provider is not None:
|
||||
pn = getattr(provider, "provider_name", None)
|
||||
if callable(pn):
|
||||
provider_name = pn()
|
||||
elif isinstance(pn, str):
|
||||
provider_name = pn
|
||||
is_conf = getattr(provider, "is_configured", None)
|
||||
if callable(is_conf):
|
||||
is_available = bool(is_conf())
|
||||
|
||||
shape = {
|
||||
"is_local": _is_local_mode(),
|
||||
"provider_name": provider_name,
|
||||
"is_available": is_available,
|
||||
}
|
||||
print(json.dumps(shape))
|
||||
"""
|
||||
|
||||
|
||||
SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
|
||||
# (label, config.yaml body, extra env vars)
|
||||
("no-config-no-env", "", {}),
|
||||
("explicit-local-no-env", "browser:\n cloud_provider: local\n", {}),
|
||||
(
|
||||
"explicit-browserbase-no-creds",
|
||||
"browser:\n cloud_provider: browserbase\n",
|
||||
{},
|
||||
),
|
||||
(
|
||||
"explicit-browserbase-with-creds",
|
||||
"browser:\n cloud_provider: browserbase\n",
|
||||
{"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
|
||||
),
|
||||
(
|
||||
"explicit-browser-use-no-creds",
|
||||
"browser:\n cloud_provider: browser-use\n",
|
||||
{},
|
||||
),
|
||||
(
|
||||
"explicit-browser-use-with-creds",
|
||||
"browser:\n cloud_provider: browser-use\n",
|
||||
{"BROWSER_USE_API_KEY": "k"},
|
||||
),
|
||||
(
|
||||
"explicit-firecrawl-no-creds",
|
||||
"browser:\n cloud_provider: firecrawl\n",
|
||||
{},
|
||||
),
|
||||
(
|
||||
"explicit-firecrawl-with-creds",
|
||||
"browser:\n cloud_provider: firecrawl\n",
|
||||
{"FIRECRAWL_API_KEY": "k"},
|
||||
),
|
||||
(
|
||||
"no-config-bu-creds",
|
||||
"",
|
||||
{"BROWSER_USE_API_KEY": "k"},
|
||||
),
|
||||
(
|
||||
"no-config-bb-creds",
|
||||
"",
|
||||
{"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
|
||||
),
|
||||
(
|
||||
"no-config-both-creds",
|
||||
"",
|
||||
{
|
||||
"BROWSER_USE_API_KEY": "k",
|
||||
"BROWSERBASE_API_KEY": "x",
|
||||
"BROWSERBASE_PROJECT_ID": "y",
|
||||
},
|
||||
),
|
||||
(
|
||||
"no-config-firecrawl-only",
|
||||
"",
|
||||
{"FIRECRAWL_API_KEY": "k"},
|
||||
),
|
||||
(
|
||||
"no-config-firecrawl-and-bb",
|
||||
"",
|
||||
{
|
||||
"FIRECRAWL_API_KEY": "k",
|
||||
"BROWSERBASE_API_KEY": "x",
|
||||
"BROWSERBASE_PROJECT_ID": "y",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
|
||||
"""Run one (version, scenario) cell. Returns the shape dict."""
|
||||
venv_python = repo_path / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
# Worktrees share the main repo's venv.
|
||||
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = Path("python3")
|
||||
|
||||
out = subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
SUBPROCESS_SCRIPT,
|
||||
str(repo_path),
|
||||
json.dumps(env),
|
||||
config_yaml,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return {
|
||||
"error": "subprocess failed",
|
||||
"stdout": out.stdout,
|
||||
"stderr": out.stderr[-500:],
|
||||
}
|
||||
try:
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
except Exception as exc:
|
||||
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
|
||||
|
||||
|
||||
def _reduce_for_comparison(shape: dict) -> dict:
|
||||
"""Reduce a shape dict to the parts that matter for user-visible parity.
|
||||
|
||||
We compare ``(is_local, provider_name, is_available)`` — the trio that
|
||||
decides what the dispatcher does with each tool call. ``provider_name``
|
||||
is the legacy ``provider_name()`` return value ('Browserbase' / 'Browser
|
||||
Use' / 'Firecrawl'), which is identical between legacy and plugin
|
||||
classes (the plugin's ``display_name`` matches the legacy
|
||||
``provider_name()`` return).
|
||||
"""
|
||||
return {
|
||||
"is_local": shape.get("is_local"),
|
||||
"provider_name": shape.get("provider_name"),
|
||||
"is_available": shape.get("is_available"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"main: {MAIN_DIR}")
|
||||
print(f"pr: {PR_DIR}")
|
||||
print()
|
||||
|
||||
failures: list[str] = []
|
||||
errors: list[str] = []
|
||||
for label, config_yaml, env in SCENARIOS:
|
||||
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
|
||||
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
|
||||
|
||||
if "error" in main_shape or "error" in pr_shape:
|
||||
print(f" [ERR ] {label}: subprocess failed")
|
||||
print(f" main: {main_shape}")
|
||||
print(f" pr: {pr_shape}")
|
||||
errors.append(label)
|
||||
continue
|
||||
|
||||
main_reduced = _reduce_for_comparison(main_shape)
|
||||
pr_reduced = _reduce_for_comparison(pr_shape)
|
||||
|
||||
if main_reduced == pr_reduced:
|
||||
print(f" [OK] {label}: {main_reduced}")
|
||||
else:
|
||||
print(f" [FAIL] {label}")
|
||||
print(f" main: {main_reduced}")
|
||||
print(f" pr: {pr_reduced}")
|
||||
failures.append(label)
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
if failures:
|
||||
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
if failures or errors:
|
||||
return 1
|
||||
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Plugin-side tests for the browser provider migration (PR #25214).
|
||||
|
||||
Covers:
|
||||
|
||||
- All three bundled plugins (browserbase, browser-use, firecrawl)
|
||||
instantiate and self-report the expected ABC defaults.
|
||||
- Each plugin's ``is_available()`` correctly reflects env-var presence.
|
||||
- The browser_registry resolves an active provider in the documented
|
||||
scenarios:
|
||||
* explicit config wins ignoring availability (so dispatcher surfaces
|
||||
a typed credentials error)
|
||||
* legacy preference walk: browser-use → browserbase (filtered by
|
||||
availability)
|
||||
* firecrawl is NOT in the legacy walk — explicit-only
|
||||
* unknown name falls through to auto-detect
|
||||
* ``local`` short-circuits to None
|
||||
|
||||
These tests use *real* imports from the plugin modules — no mocking of
|
||||
provider classes themselves — so the test catches drift in the ABC
|
||||
interface, the registry, and the plugin glue layer simultaneously.
|
||||
Mirrors ``tests/plugins/web/test_web_search_provider_plugins.py`` from
|
||||
PR #25182.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clear_browser_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Strip every browser-provider env var so is_available() returns False."""
|
||||
for k in (
|
||||
"BROWSERBASE_API_KEY",
|
||||
"BROWSERBASE_PROJECT_ID",
|
||||
"BROWSERBASE_BASE_URL",
|
||||
"BROWSER_USE_API_KEY",
|
||||
"BROWSER_USE_GATEWAY_URL",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_BROWSER_TTL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
def _ensure_plugins_loaded() -> None:
|
||||
"""Idempotently load plugins so the registry is populated."""
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-test isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Each test starts with a clean browser-provider env."""
|
||||
_clear_browser_env(monkeypatch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled plugins register
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBundledPluginsRegister:
|
||||
"""All three bundled browser plugins discover and register correctly."""
|
||||
|
||||
def test_all_three_plugins_present_in_registry(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import list_providers
|
||||
|
||||
names = sorted(p.name for p in list_providers())
|
||||
assert names == ["browser-use", "browserbase", "firecrawl"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name,expected_display",
|
||||
[
|
||||
("browserbase", "Browserbase"),
|
||||
("browser-use", "Browser Use"),
|
||||
("firecrawl", "Firecrawl"),
|
||||
],
|
||||
)
|
||||
def test_each_plugin_has_name_and_display_name(
|
||||
self, plugin_name: str, expected_display: str
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None, f"plugin {plugin_name!r} not registered"
|
||||
assert provider.name == plugin_name
|
||||
assert provider.display_name == expected_display
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["browserbase", "browser-use", "firecrawl"],
|
||||
)
|
||||
def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
|
||||
"""``get_setup_schema()`` returns a dict the picker can consume."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None
|
||||
schema = provider.get_setup_schema()
|
||||
assert isinstance(schema, dict)
|
||||
assert "name" in schema
|
||||
assert "env_vars" in schema
|
||||
# Every cloud-browser plugin needs the agent-browser post-setup hook
|
||||
# so the picker auto-installs the CLI on selection.
|
||||
assert schema.get("post_setup") == "agent_browser"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["browserbase", "browser-use", "firecrawl"],
|
||||
)
|
||||
def test_each_plugin_implements_full_lifecycle(self, plugin_name: str) -> None:
|
||||
"""The ABC's three lifecycle methods are all overridden."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None
|
||||
# Each method must be a real override, not the ABC's NotImplementedError
|
||||
# default — we check by comparing the function reference.
|
||||
assert type(provider).create_session is not BrowserProvider.create_session
|
||||
assert type(provider).close_session is not BrowserProvider.close_session
|
||||
assert (
|
||||
type(provider).emergency_cleanup is not BrowserProvider.emergency_cleanup
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_available() behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
"""Each plugin's ``is_available()`` reflects env-var presence accurately."""
|
||||
|
||||
def test_browserbase_requires_both_api_key_and_project_id(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
p = get_provider("browserbase")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
|
||||
# API key alone is insufficient.
|
||||
monkeypatch.setenv("BROWSERBASE_API_KEY", "key")
|
||||
assert p.is_available() is False
|
||||
|
||||
# Both env vars set → available.
|
||||
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_browserbase_project_id_alone_insufficient(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
p = get_provider("browserbase")
|
||||
assert p is not None
|
||||
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
|
||||
assert p.is_available() is False
|
||||
|
||||
def test_browser_use_satisfied_by_api_key(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
p = get_provider("browser-use")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "key")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_firecrawl_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "key")
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry resolution semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryResolution:
|
||||
"""``_resolve()`` implements the documented three-rule precedence."""
|
||||
|
||||
def test_resolve_none_with_no_creds_returns_none(self) -> None:
|
||||
"""No config, no env → local mode (None)."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
assert _resolve(None) is None
|
||||
|
||||
def test_explicit_local_returns_none(self) -> None:
|
||||
"""``cloud_provider: local`` is a positive choice; short-circuits to None."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
assert _resolve("local") is None
|
||||
|
||||
def test_explicit_browserbase_returns_provider_even_when_unavailable(self) -> None:
|
||||
"""Rule 1: explicit-config wins even when credentials are missing.
|
||||
|
||||
This is critical — the dispatcher needs to surface a typed
|
||||
credentials error rather than silently switching backends.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
provider = _resolve("browserbase")
|
||||
assert provider is not None
|
||||
assert provider.name == "browserbase"
|
||||
assert provider.is_available() is False # confirms "ignoring availability"
|
||||
|
||||
def test_explicit_firecrawl_returns_provider_even_when_unavailable(self) -> None:
|
||||
"""Firecrawl behaves the same as browserbase under explicit config."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
provider = _resolve("firecrawl")
|
||||
assert provider is not None
|
||||
assert provider.name == "firecrawl"
|
||||
|
||||
def test_explicit_unknown_falls_back_to_auto_detect(self) -> None:
|
||||
"""Rule 1 miss: unknown name → fall through to legacy walk."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
# With no credentials anywhere, auto-detect should also fail.
|
||||
assert _resolve("not-a-real-provider") is None
|
||||
|
||||
def test_legacy_walk_prefers_browser_use_over_browserbase(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Rule 3: walk order is browser-use → browserbase."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
# Both available — browser-use should win.
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "k1")
|
||||
monkeypatch.setenv("BROWSERBASE_API_KEY", "k2")
|
||||
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
|
||||
|
||||
provider = _resolve(None)
|
||||
assert provider is not None
|
||||
assert provider.name == "browser-use"
|
||||
|
||||
def test_legacy_walk_falls_through_to_browserbase(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Rule 3: browser-use unavailable → browserbase picked."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
monkeypatch.setenv("BROWSERBASE_API_KEY", "k")
|
||||
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
|
||||
|
||||
provider = _resolve(None)
|
||||
assert provider is not None
|
||||
assert provider.name == "browserbase"
|
||||
|
||||
def test_firecrawl_not_in_legacy_walk_even_when_only_one_available(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Regression: firecrawl is NEVER auto-selected even when single-eligible.
|
||||
|
||||
Pre-PR-#25214, the dispatcher only auto-detected between Browser Use
|
||||
and Browserbase; firecrawl was reachable solely via explicit
|
||||
config. We preserve that gate because FIRECRAWL_API_KEY is shared
|
||||
with the *web* firecrawl plugin — auto-routing a web-extract user
|
||||
to a paid cloud browser would be a real behaviour regression.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import _resolve
|
||||
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "k")
|
||||
|
||||
# Only firecrawl is_available() — but it's not in the legacy walk.
|
||||
assert _resolve(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy ABC backward-compat aliases (is_configured / provider_name)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLegacyAbcAliases:
|
||||
"""is_configured() and provider_name() delegate to the new API."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["browserbase", "browser-use", "firecrawl"],
|
||||
)
|
||||
def test_is_configured_delegates_to_is_available(self, plugin_name: str) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
p = get_provider(plugin_name)
|
||||
assert p is not None
|
||||
assert p.is_configured() is p.is_available()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name,expected_label",
|
||||
[
|
||||
("browserbase", "Browserbase"),
|
||||
("browser-use", "Browser Use"),
|
||||
("firecrawl", "Firecrawl"),
|
||||
],
|
||||
)
|
||||
def test_provider_name_returns_display_name(
|
||||
self, plugin_name: str, expected_label: str
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.browser_registry import get_provider
|
||||
|
||||
p = get_provider(plugin_name)
|
||||
assert p is not None
|
||||
assert p.provider_name() == expected_label
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Picker integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPickerIntegration:
|
||||
"""`_plugin_browser_providers()` exposes all three plugins as picker rows."""
|
||||
|
||||
def test_picker_rows_match_registered_plugins(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from hermes_cli.tools_config import _plugin_browser_providers
|
||||
|
||||
rows = _plugin_browser_providers()
|
||||
names = sorted(r.get("browser_provider") for r in rows)
|
||||
assert names == ["browser-use", "browserbase", "firecrawl"]
|
||||
|
||||
def test_picker_rows_carry_post_setup_hook(self) -> None:
|
||||
"""Every browser plugin row has post_setup='agent_browser' so
|
||||
selecting it triggers the agent-browser CLI install."""
|
||||
_ensure_plugins_loaded()
|
||||
from hermes_cli.tools_config import _plugin_browser_providers
|
||||
|
||||
for row in _plugin_browser_providers():
|
||||
assert row.get("post_setup") == "agent_browser", (
|
||||
f"plugin row {row['browser_provider']!r} missing post_setup hook"
|
||||
)
|
||||
|
||||
def test_picker_rows_carry_browser_plugin_name_marker(self) -> None:
|
||||
"""`browser_plugin_name` matches `browser_provider` so downstream
|
||||
code can route through the registry when it wants to."""
|
||||
_ensure_plugins_loaded()
|
||||
from hermes_cli.tools_config import _plugin_browser_providers
|
||||
|
||||
for row in _plugin_browser_providers():
|
||||
assert row.get("browser_plugin_name") == row.get("browser_provider")
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for the BasicAuthProvider plugin (username/password, scrypt, signed
|
||||
tokens).
|
||||
|
||||
Loads the plugin module directly (it's a bundled backend plugin, not on the
|
||||
import path as a package) and exercises the provider behaviour + the
|
||||
``register(ctx)`` entry point's config/env resolution and skip reasons.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.dashboard_auth.basic as basic_plugin
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCredentialsError,
|
||||
RefreshExpiredError,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def basic():
|
||||
return basic_plugin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_basic_env(monkeypatch):
|
||||
for var in (
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
def test_hash_then_verify_round_trips(self, basic):
|
||||
h = basic.hash_password("hunter2")
|
||||
assert h.startswith("scrypt$")
|
||||
assert basic._verify_password("hunter2", h)
|
||||
|
||||
def test_wrong_password_fails(self, basic):
|
||||
h = basic.hash_password("hunter2")
|
||||
assert not basic._verify_password("wrong", h)
|
||||
|
||||
def test_malformed_hash_returns_false(self, basic):
|
||||
assert not basic._verify_password("x", "not-a-valid-hash")
|
||||
assert not basic._verify_password("x", "bcrypt$wrong$scheme")
|
||||
|
||||
def test_two_hashes_of_same_password_differ(self, basic):
|
||||
# Distinct random salts → distinct encoded hashes.
|
||||
assert basic.hash_password("pw") != basic.hash_password("pw")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvider:
|
||||
def _make(self, basic, **kw):
|
||||
h = basic.hash_password("hunter2")
|
||||
return basic.BasicAuthProvider(
|
||||
username="admin",
|
||||
password_hash=h,
|
||||
secret=secrets.token_bytes(32),
|
||||
**kw,
|
||||
)
|
||||
|
||||
def test_protocol_compliant(self, basic):
|
||||
assert assert_protocol_compliance(basic.BasicAuthProvider) is None
|
||||
|
||||
def test_supports_password_true(self, basic):
|
||||
assert basic.BasicAuthProvider.supports_password is True
|
||||
|
||||
def test_login_mints_session(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
assert s.user_id == "admin"
|
||||
assert s.provider == "basic"
|
||||
assert s.access_token and s.refresh_token
|
||||
|
||||
def test_bad_credentials_raise(self, basic):
|
||||
p = self._make(basic)
|
||||
for u, pw in [("admin", "wrong"), ("ghost", "hunter2"), ("", "")]:
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
p.complete_password_login(username=u, password=pw)
|
||||
|
||||
def test_verify_round_trips_and_rejects_tamper(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
assert p.verify_session(access_token=s.access_token) is not None
|
||||
assert p.verify_session(access_token="garbage") is None
|
||||
|
||||
def test_access_token_not_accepted_as_refresh(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
# A refresh token must not verify as an access token and vice
|
||||
# versa — the ``kind`` claim is enforced.
|
||||
assert p.verify_session(access_token=s.refresh_token) is None
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
p.refresh_session(refresh_token=s.access_token)
|
||||
|
||||
def test_refresh_round_trips(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
r = p.refresh_session(refresh_token=s.refresh_token)
|
||||
assert r.user_id == "admin"
|
||||
assert p.verify_session(access_token=r.access_token) is not None
|
||||
|
||||
def test_refresh_with_garbage_raises(self, basic):
|
||||
p = self._make(basic)
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
p.refresh_session(refresh_token="garbage")
|
||||
|
||||
def test_cross_secret_token_does_not_verify(self, basic):
|
||||
p1 = self._make(basic)
|
||||
p2 = self._make(basic) # different random secret
|
||||
s = p1.complete_password_login(username="admin", password="hunter2")
|
||||
assert p2.verify_session(access_token=s.access_token) is None
|
||||
|
||||
def test_revoke_is_silent(self, basic):
|
||||
p = self._make(basic)
|
||||
p.revoke_session(refresh_token="anything") # must not raise
|
||||
|
||||
def test_oauth_methods_raise_not_implemented(self, basic):
|
||||
p = self._make(basic)
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.start_login(redirect_uri="https://x/auth/callback")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.complete_login(
|
||||
code="c", state="s", code_verifier="v", redirect_uri="r"
|
||||
)
|
||||
|
||||
def test_construction_validates_inputs(self, basic):
|
||||
good_hash = basic.hash_password("pw")
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="", password_hash=good_hash, secret=b"x" * 32
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="admin", password_hash="", secret=b"x" * 32
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="admin", password_hash=good_hash, secret=b"short"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register() entry point — config/env resolution + skip reasons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_skips_when_no_username(self, basic, monkeypatch):
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "username" in basic.LAST_SKIP_REASON
|
||||
|
||||
def test_skips_when_username_but_no_password(self, basic, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "password" in basic.LAST_SKIP_REASON
|
||||
|
||||
def test_registers_with_env_plaintext_password(self, basic, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(provider, basic.BasicAuthProvider)
|
||||
# Round-trips: the registered provider authenticates the env creds.
|
||||
s = provider.complete_password_login(username="admin", password="hunter2")
|
||||
assert s.user_id == "admin"
|
||||
assert basic.LAST_SKIP_REASON == ""
|
||||
|
||||
def test_registers_with_precomputed_hash(self, basic, monkeypatch):
|
||||
h = basic.hash_password("s3cret")
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"_load_config_basic_auth_section",
|
||||
lambda: {"username": "ops", "password_hash": h},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert provider.complete_password_login(
|
||||
username="ops", password="s3cret"
|
||||
).user_id == "ops"
|
||||
|
||||
def test_env_password_overrides_config(self, basic, monkeypatch):
|
||||
cfg_hash = basic.hash_password("config-pw")
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"_load_config_basic_auth_section",
|
||||
lambda: {"username": "admin", "password_hash": cfg_hash},
|
||||
)
|
||||
# Env plaintext should win over the config hash.
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "env-pw")
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
# env password works ...
|
||||
assert provider.complete_password_login(
|
||||
username="admin", password="env-pw"
|
||||
)
|
||||
# ... and the config password no longer does.
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
provider.complete_password_login(username="admin", password="config-pw")
|
||||
|
||||
def test_explicit_secret_makes_sessions_portable(self, basic, monkeypatch):
|
||||
# Two providers built from the SAME explicit secret accept each
|
||||
# other's tokens (the restart-/multi-worker-survival contract).
|
||||
shared = secrets.token_bytes(32).hex()
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_SECRET", shared)
|
||||
|
||||
ctx1, ctx2 = MagicMock(), MagicMock()
|
||||
basic.register(ctx1)
|
||||
basic.register(ctx2)
|
||||
p1 = ctx1.register_dashboard_auth_provider.call_args.args[0]
|
||||
p2 = ctx2.register_dashboard_auth_provider.call_args.args[0]
|
||||
s = p1.complete_password_login(username="admin", password="hunter2")
|
||||
assert p2.verify_session(access_token=s.access_token) is not None
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for the DrainSecretProvider plugin (non-interactive bearer secret).
|
||||
|
||||
Task 2.0b. Loads the bundled drain plugin module directly and exercises:
|
||||
* the entropy gate (assess_secret_strength) — fail-closed on weak secrets,
|
||||
* constant-time verify_token returning a scoped TokenPrincipal,
|
||||
* the register(ctx) entry point's env/config resolution, skip reasons, and
|
||||
token-route registration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.dashboard_auth.drain as drain_plugin
|
||||
from hermes_cli.dashboard_auth import TokenPrincipal, assert_protocol_compliance
|
||||
from hermes_cli.dashboard_auth import token_auth
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def drain():
|
||||
return drain_plugin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env_and_routes(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_DRAIN_SECRET", raising=False)
|
||||
token_auth.clear_token_routes()
|
||||
yield
|
||||
token_auth.clear_token_routes()
|
||||
|
||||
|
||||
def _strong_secret() -> str:
|
||||
# token_urlsafe(32) → 43 url-safe-b64 chars ≈ 256 bits.
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entropy gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntropyGate:
|
||||
def test_strong_secret_passes(self, drain):
|
||||
assert drain.assess_secret_strength(_strong_secret()) is None
|
||||
|
||||
def test_empty_rejected(self, drain):
|
||||
assert drain.assess_secret_strength("") is not None
|
||||
|
||||
def test_too_short_rejected(self, drain):
|
||||
# 42 chars — one under the 43-char bar.
|
||||
assert drain.assess_secret_strength("a1B2c3" * 7) is not None
|
||||
|
||||
def test_long_but_repeated_rejected(self, drain):
|
||||
# 60 chars, one distinct character → low distinct count + low entropy.
|
||||
assert drain.assess_secret_strength("a" * 60) is not None
|
||||
|
||||
def test_long_but_few_distinct_rejected(self, drain):
|
||||
# 60 chars cycling through only 4 distinct characters.
|
||||
assert drain.assess_secret_strength("abcd" * 15) is not None
|
||||
|
||||
def test_custom_min_chars_enforced(self, drain):
|
||||
s = _strong_secret() # 43 chars
|
||||
assert drain.assess_secret_strength(s, min_chars=999) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvider:
|
||||
def test_protocol_compliance(self, drain):
|
||||
assert_protocol_compliance(drain.DrainSecretProvider)
|
||||
|
||||
def test_supports_token_flag(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.supports_token is True
|
||||
|
||||
def test_is_non_interactive(self, drain):
|
||||
# Excluded from interactive surfaces via list_session_providers().
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.supports_session is False
|
||||
|
||||
def test_verify_token_accepts_matching_secret(self, drain):
|
||||
s = _strong_secret()
|
||||
p = drain.DrainSecretProvider(secret=s, scope="drain")
|
||||
principal = p.verify_token(token=s)
|
||||
assert isinstance(principal, TokenPrincipal)
|
||||
assert principal.principal == "drain-control"
|
||||
assert principal.provider == "drain-secret"
|
||||
assert principal.scopes == ("drain",)
|
||||
|
||||
def test_verify_token_rejects_wrong_secret(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.verify_token(token=_strong_secret()) is None
|
||||
|
||||
def test_verify_token_rejects_empty(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.verify_token(token="") is None
|
||||
|
||||
def test_custom_scope_attached(self, drain):
|
||||
s = _strong_secret()
|
||||
p = drain.DrainSecretProvider(secret=s, scope="lifecycle")
|
||||
assert p.verify_token(token=s).scopes == ("lifecycle",)
|
||||
|
||||
def test_construction_rejects_weak_secret(self, drain):
|
||||
with pytest.raises(ValueError):
|
||||
drain.DrainSecretProvider(secret="weak")
|
||||
|
||||
def test_verify_session_returns_none_not_raises(self, drain):
|
||||
# Stacks harmlessly in the cookie-verify loop.
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
assert p.verify_session(access_token="anything") is None
|
||||
|
||||
def test_interactive_methods_raise(self, drain):
|
||||
p = drain.DrainSecretProvider(secret=_strong_secret())
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.start_login(redirect_uri="r")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.complete_login(code="c", state="s", code_verifier="v", redirect_uri="r")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.refresh_session(refresh_token="r")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register() entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_skips_when_no_secret(self, drain, monkeypatch):
|
||||
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "HERMES_DASHBOARD_DRAIN_SECRET" in drain.LAST_SKIP_REASON
|
||||
assert not token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
|
||||
|
||||
def test_skips_and_fails_closed_on_weak_secret(self, drain, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", "tooweak")
|
||||
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "rejected" in drain.LAST_SKIP_REASON
|
||||
# fail-closed: the route is NOT token-authable, so it stays gated.
|
||||
assert not token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
|
||||
|
||||
def test_registers_with_strong_env_secret(self, drain, monkeypatch):
|
||||
s = _strong_secret()
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
|
||||
monkeypatch.setattr(drain, "_load_config_drain_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(provider, drain.DrainSecretProvider)
|
||||
assert provider.verify_token(token=s) is not None
|
||||
assert drain.LAST_SKIP_REASON == ""
|
||||
# The drain endpoint is now token-authable.
|
||||
assert token_auth.is_token_route(drain.DRAIN_ROUTE_PATH)
|
||||
|
||||
def test_config_scope_applied(self, drain, monkeypatch):
|
||||
s = _strong_secret()
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
|
||||
monkeypatch.setattr(
|
||||
drain, "_load_config_drain_auth_section", lambda: {"scope": "lifecycle"}
|
||||
)
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert provider.verify_token(token=s).scopes == ("lifecycle",)
|
||||
|
||||
def test_config_min_secret_chars_can_reject_otherwise_ok_secret(
|
||||
self, drain, monkeypatch
|
||||
):
|
||||
s = _strong_secret() # 43 chars — fine by default, too short at 999
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_DRAIN_SECRET", s)
|
||||
monkeypatch.setattr(
|
||||
drain,
|
||||
"_load_config_drain_auth_section",
|
||||
lambda: {"min_secret_chars": 999},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
drain.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "rejected" in drain.LAST_SKIP_REASON
|
||||
@@ -0,0 +1,848 @@
|
||||
"""Tests for the bundled Nous dashboard-auth plugin.
|
||||
|
||||
Covers four shapes from Phase 4 of ``.hermes/plans/2026-05-21-dashboard-oauth-auth.md``:
|
||||
|
||||
1. Plugin entry-point registration gating (env var checks).
|
||||
2. ``start_login`` shape (PKCE/state, authorize URL parameters).
|
||||
3. ``complete_login`` httpx-mocked happy path + error mapping.
|
||||
4. ``verify_session`` JWT verification — RSA keypair, audience/issuer pinning,
|
||||
``agent_instance_id`` cross-check, ``oauth_contract_version`` tolerance.
|
||||
|
||||
Also exercises ``revoke_session`` (no-op) and ``refresh_session``
|
||||
(unconditional ``RefreshExpiredError``).
|
||||
|
||||
All HTTP is mocked: nothing in this file talks to a real Portal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import plugins.dashboard_auth.nous as nous_plugin
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSA keypair fixture (module-scope — keygen is slow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keypair() -> Dict[str, Any]:
|
||||
"""Generate an RS256 keypair + matching JWK for verify_session tests."""
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
public_numbers = key.public_key().public_numbers()
|
||||
|
||||
def _b64url_uint(n: int) -> str:
|
||||
length = (n.bit_length() + 7) // 8
|
||||
return (
|
||||
base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
||||
)
|
||||
|
||||
jwk = {
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": "test-key-1",
|
||||
"n": _b64url_uint(public_numbers.n),
|
||||
"e": _b64url_uint(public_numbers.e),
|
||||
}
|
||||
return {"private_pem": private_pem, "jwk": jwk, "kid": jwk["kid"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-mint helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mint_token(
|
||||
rsa_keypair: Dict[str, Any],
|
||||
*,
|
||||
iss: str = "https://portal.example.com",
|
||||
aud: str = "agent:inst123",
|
||||
sub: str = "usr_abc",
|
||||
agent_instance_id: str | None = "inst123",
|
||||
oauth_contract_version: Any = 1,
|
||||
org_id: str | None = "org_xyz",
|
||||
scope: str = "agent_dashboard:access",
|
||||
ttl_seconds: int = 900,
|
||||
extra_claims: Dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"sub": sub,
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
"scope": scope,
|
||||
}
|
||||
if agent_instance_id is not None:
|
||||
claims["agent_instance_id"] = agent_instance_id
|
||||
if oauth_contract_version is not None:
|
||||
claims["oauth_contract_version"] = oauth_contract_version
|
||||
if org_id is not None:
|
||||
claims["org_id"] = org_id
|
||||
if extra_claims:
|
||||
claims.update(extra_claims)
|
||||
return jwt.encode(
|
||||
claims,
|
||||
rsa_keypair["private_pem"],
|
||||
algorithm="RS256",
|
||||
headers={"kid": rsa_keypair["kid"]},
|
||||
)
|
||||
|
||||
|
||||
def _patched_jwks(provider: nous_plugin.NousDashboardAuthProvider, rsa_keypair):
|
||||
"""Patch the provider's JWKS client to return our fixture key."""
|
||||
fake_key = MagicMock()
|
||||
fake_key.key = serialization.load_pem_private_key(
|
||||
rsa_keypair["private_pem"].encode(), password=None
|
||||
).public_key()
|
||||
fake_client = MagicMock()
|
||||
fake_client.get_signing_key_from_jwt.return_value = fake_key
|
||||
provider._jwks_client = fake_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_protocol_compliance(self):
|
||||
assert_protocol_compliance(nous_plugin.NousDashboardAuthProvider)
|
||||
|
||||
def test_name_and_display(self):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst1", portal_url="https://portal.example.com"
|
||||
)
|
||||
assert p.name == "nous"
|
||||
assert p.display_name == "Nous Research"
|
||||
|
||||
def test_extracts_agent_instance_id(self):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:abc-123", portal_url="https://portal.example.com"
|
||||
)
|
||||
assert p._agent_instance_id == "abc-123"
|
||||
|
||||
def test_strips_trailing_slash_from_portal_url(self):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:x", portal_url="https://portal.example.com/"
|
||||
)
|
||||
assert p._portal_url == "https://portal.example.com"
|
||||
|
||||
def test_rejects_malformed_client_id(self):
|
||||
with pytest.raises(ValueError, match="agent:"):
|
||||
nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="hermes-dashboard", portal_url="https://x"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point: env-gated registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPluginRegister:
|
||||
def test_skips_when_client_id_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
# Skip reason is surfaced for the gate's fail-closed message.
|
||||
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
|
||||
|
||||
def test_registers_with_default_portal_url_when_only_client_id_set(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""Phase 7 follow-up: HERMES_DASHBOARD_PORTAL_URL is optional —
|
||||
defaults to the production Nous Portal. The user shouldn't have
|
||||
to set it for the common production deployment path."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(registered, nous_plugin.NousDashboardAuthProvider)
|
||||
assert registered._portal_url == "https://portal.nousresearch.com"
|
||||
# Skip reason cleared on successful registration.
|
||||
assert nous_plugin.LAST_SKIP_REASON == ""
|
||||
|
||||
def test_skips_when_client_id_malformed(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "hermes-dashboard")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "https://p.example")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
# Skip reason names the offending value + contract shape.
|
||||
assert "agent:" in nous_plugin.LAST_SKIP_REASON
|
||||
assert "hermes-dashboard" in nous_plugin.LAST_SKIP_REASON
|
||||
|
||||
def test_registers_with_explicit_portal_url(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "https://p.example")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:inst1"
|
||||
assert registered._portal_url == "https://p.example"
|
||||
|
||||
def test_strips_whitespace_from_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", " agent:x ")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", " https://p.example ")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
|
||||
def test_empty_portal_url_env_uses_default(self, monkeypatch):
|
||||
"""Explicit empty string still falls back to the production
|
||||
default — same handling as 'unset' so an empty Fly secret can't
|
||||
accidentally point the dashboard at nowhere."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:inst1")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PORTAL_URL", "")
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._portal_url == "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point: config.yaml + env-override precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigYamlSource:
|
||||
"""``dashboard.oauth.{client_id,portal_url}`` in ``config.yaml`` is the
|
||||
canonical surface for these settings. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``
|
||||
and ``HERMES_DASHBOARD_PORTAL_URL`` are operator overrides that win when
|
||||
set — this is the contract Fly.io's platform-secret injection relies on,
|
||||
and the contract that lets local devs experiment without setting env
|
||||
vars.
|
||||
|
||||
Each test pins exactly one tier of the precedence chain so a regression
|
||||
that flips the order is caught:
|
||||
|
||||
env (when truthy) > config.yaml (when truthy) > plugin default
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def patch_config(self, monkeypatch):
|
||||
"""Yield a callable that replaces ``hermes_cli.config.load_config``
|
||||
with a stub returning the given dict. Tests pass the intended
|
||||
``dashboard.oauth`` block; the stub returns the wrapping structure."""
|
||||
|
||||
def _set(oauth_block: Dict[str, Any] | None) -> None:
|
||||
cfg = {}
|
||||
if oauth_block is not None:
|
||||
cfg = {"dashboard": {"oauth": oauth_block}}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: cfg
|
||||
)
|
||||
|
||||
return _set
|
||||
|
||||
def test_config_yaml_only_client_id_registers(self, patch_config, monkeypatch):
|
||||
"""No env var, only config.yaml — plugin reads from config and
|
||||
registers successfully. This is the path Teknium's review pushed
|
||||
for (".env is for secrets only")."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
patch_config({"client_id": "agent:from-config"})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-config"
|
||||
# Defaults to production portal URL when neither config nor env
|
||||
# specifies one.
|
||||
assert registered._portal_url == "https://portal.nousresearch.com"
|
||||
|
||||
def test_config_yaml_client_id_and_portal_url(self, patch_config, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
|
||||
patch_config({
|
||||
"client_id": "agent:from-config",
|
||||
"portal_url": "https://staging.portal.example",
|
||||
})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-config"
|
||||
assert registered._portal_url == "https://staging.portal.example"
|
||||
|
||||
def test_env_overrides_config_client_id(self, patch_config, monkeypatch):
|
||||
"""Env wins. Critical for Fly.io: the Portal injects
|
||||
HERMES_DASHBOARD_OAUTH_CLIENT_ID at deploy time and we MUST
|
||||
honour it even if a stale config.yaml ships in the image."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:from-env")
|
||||
patch_config({"client_id": "agent:from-config"})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-env", (
|
||||
"env var must override config.yaml — Fly secret injection "
|
||||
"depends on this precedence"
|
||||
)
|
||||
|
||||
def test_env_overrides_config_portal_url(self, patch_config, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:x")
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PORTAL_URL", "https://env.portal.example",
|
||||
)
|
||||
patch_config({
|
||||
"client_id": "agent:x",
|
||||
"portal_url": "https://config.portal.example",
|
||||
})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._portal_url == "https://env.portal.example"
|
||||
|
||||
def test_empty_env_string_does_not_shadow_config(
|
||||
self, patch_config, monkeypatch
|
||||
):
|
||||
"""``HERMES_DASHBOARD_OAUTH_CLIENT_ID=`` (set but empty) is
|
||||
common in CI/Fly when a secret is provisioned-but-not-populated.
|
||||
It MUST NOT shadow a valid config.yaml value with an empty
|
||||
string — operators would lose the gate."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "")
|
||||
patch_config({"client_id": "agent:from-config"})
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_id == "agent:from-config"
|
||||
|
||||
def test_neither_source_skips_with_helpful_reason(
|
||||
self, patch_config, monkeypatch
|
||||
):
|
||||
"""Neither env nor config.yaml set — skip with a reason that
|
||||
mentions BOTH surfaces so operators don't guess wrong about
|
||||
which one to populate."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
patch_config(None)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
# Old behaviour: skip reason mentions the env var.
|
||||
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
|
||||
# New behaviour: skip reason ALSO mentions the config.yaml path
|
||||
# so the user knows it's a valid alternative.
|
||||
assert "dashboard.oauth.client_id" in nous_plugin.LAST_SKIP_REASON, (
|
||||
f"skip reason omits the config.yaml surface — operators "
|
||||
f"won't know it exists. got: {nous_plugin.LAST_SKIP_REASON!r}"
|
||||
)
|
||||
|
||||
def test_config_yaml_load_failure_falls_through_cleanly(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""If load_config() raises (e.g. malformed YAML, IOError), the
|
||||
plugin must not crash — it falls through to the env-only path
|
||||
and either succeeds (if env is set) or surfaces the standard
|
||||
'not set' skip reason."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
|
||||
def _broken_load():
|
||||
raise OSError("config.yaml not readable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", _broken_load
|
||||
)
|
||||
ctx = MagicMock()
|
||||
# Must not raise.
|
||||
nous_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
def test_config_yaml_with_non_dict_oauth_section(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""cfg_get handles 'config has a string where a section was
|
||||
expected' robustly. Verify the plugin inherits that resilience
|
||||
so a malformed user config doesn't crash startup."""
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"dashboard": {"oauth": "wrong type"}},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
nous_plugin.register(ctx)
|
||||
# Falls through to the no-env-and-no-config path.
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartLogin:
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst1", portal_url="https://portal.example.com"
|
||||
)
|
||||
|
||||
def test_returns_login_start(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert isinstance(result, LoginStart)
|
||||
|
||||
def test_redirect_url_targets_portal_authorize(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert result.redirect_url.startswith(
|
||||
"https://portal.example.com/oauth/authorize?"
|
||||
)
|
||||
|
||||
def test_authorize_url_has_required_params(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
assert params["response_type"] == "code"
|
||||
assert params["client_id"] == "agent:inst1"
|
||||
assert params["redirect_uri"] == "https://hermes.fly.dev/auth/callback"
|
||||
assert params["scope"] == "agent_dashboard:access"
|
||||
assert params["code_challenge_method"] == "S256"
|
||||
assert "state" in params
|
||||
assert "code_challenge" in params
|
||||
|
||||
def test_code_verifier_in_cookie_payload_43_to_128_chars(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert "hermes_session_pkce" in result.cookie_payload
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
# Shape: ``state=…;verifier=…`` (matches stub-provider convention so
|
||||
# the auth-route layer's parser works uniformly across providers).
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
verifier = parts["verifier"]
|
||||
# RFC 7636 §4.1
|
||||
assert 43 <= len(verifier) <= 128
|
||||
|
||||
def test_state_in_cookie_payload_matches_url_param(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
assert parts["state"] == params["state"]
|
||||
|
||||
def test_code_challenge_is_s256_of_verifier(self, provider):
|
||||
result = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
parsed = urllib.parse.urlparse(result.redirect_url)
|
||||
params = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
pkce = result.cookie_payload["hermes_session_pkce"]
|
||||
parts = dict(seg.split("=", 1) for seg in pkce.split(";") if "=" in seg)
|
||||
verifier = parts["verifier"]
|
||||
expected_challenge = (
|
||||
base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
)
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
assert params["code_challenge"] == expected_challenge
|
||||
|
||||
def test_two_calls_produce_different_state_and_verifier(self, provider):
|
||||
a = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
b = provider.start_login(
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
assert a.cookie_payload["hermes_session_pkce"] != b.cookie_payload[
|
||||
"hermes_session_pkce"
|
||||
]
|
||||
|
||||
def test_rejects_non_http_scheme(self, provider):
|
||||
with pytest.raises(ProviderError, match="http"):
|
||||
provider.start_login(redirect_uri="ftp://x/auth/callback")
|
||||
|
||||
def test_allows_http_with_arbitrary_host(self, provider):
|
||||
# http:// is permitted for any host now, not just localhost — the
|
||||
# Portal-side check is authoritative on which redirect_uris are
|
||||
# accepted; this client-side fast-fail must not reject self-hosted
|
||||
# dashboards reached over plain HTTP (LAN IPs, internal hostnames,
|
||||
# TLS-terminating reverse proxies). Should not raise.
|
||||
provider.start_login(redirect_uri="http://hermes.fly.dev/auth/callback")
|
||||
provider.start_login(redirect_uri="http://192.168.1.50:8080/auth/callback")
|
||||
provider.start_login(redirect_uri="http://my-internal-host/auth/callback")
|
||||
|
||||
def test_allows_http_localhost(self, provider):
|
||||
# Should not raise.
|
||||
provider.start_login(redirect_uri="http://localhost:8080/auth/callback")
|
||||
provider.start_login(redirect_uri="http://127.0.0.1:8080/auth/callback")
|
||||
|
||||
def test_rejects_wrong_callback_path(self, provider):
|
||||
with pytest.raises(ProviderError, match="/auth/callback"):
|
||||
provider.start_login(redirect_uri="https://x.example/oauth/cb")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# complete_login (httpx mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompleteLogin:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst123", portal_url="https://portal.example.com"
|
||||
)
|
||||
_patched_jwks(p, rsa_keypair)
|
||||
return p
|
||||
|
||||
def _mock_post(self, status_code: int, body: Any, *, ctype: str = "application/json"):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
if isinstance(body, dict):
|
||||
resp.text = json.dumps(body)
|
||||
resp.json = MagicMock(return_value=body)
|
||||
else:
|
||||
resp.text = body
|
||||
# _parse_json_body bails on non-application/json before .json()
|
||||
# is called, but be safe for callers that pass a non-dict body
|
||||
# with ctype=application/json.
|
||||
resp.json = MagicMock(side_effect=ValueError("not json"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
def test_happy_path_returns_session(self, provider, rsa_keypair):
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt_initial_value",
|
||||
},
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="abc",
|
||||
state="state-val",
|
||||
code_verifier="vfy",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
assert isinstance(session, Session)
|
||||
assert session.user_id == "usr_abc"
|
||||
assert session.provider == "nous"
|
||||
assert session.access_token == access_token
|
||||
# The dashboard auth-code grant now issues a refresh token (NAS #293);
|
||||
# complete_login must surface it so the middleware persists it.
|
||||
assert session.refresh_token == "rt_initial_value"
|
||||
assert session.org_id == "org_xyz"
|
||||
assert session.email == ""
|
||||
assert session.display_name == ""
|
||||
|
||||
def test_happy_path_tolerates_missing_refresh_token(self, provider, rsa_keypair):
|
||||
# If Portal omits refresh_token (older deploy), the session is still
|
||||
# valid as access-token-only; refresh_token defaults to "".
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200, {"access_token": access_token, "token_type": "Bearer"}
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="abc",
|
||||
state="state-val",
|
||||
code_verifier="vfy",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
assert session.refresh_token == ""
|
||||
|
||||
def test_400_raises_invalid_code(self, provider):
|
||||
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(InvalidCodeError, match="invalid_grant"):
|
||||
provider.complete_login(
|
||||
code="bad", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_500_raises_provider_error(self, provider):
|
||||
mock_resp = self._mock_post(500, "internal server error", ctype="text/plain")
|
||||
mock_resp.text = "internal server error"
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="500"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_missing_access_token_raises(self, provider):
|
||||
mock_resp = self._mock_post(200, {"token_type": "Bearer"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="access_token"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_unexpected_token_type_raises(self, provider, rsa_keypair):
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200, {"access_token": access_token, "token_type": "DPoP"}
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="token_type"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post",
|
||||
side_effect=httpx.ConnectError("conn refused"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
|
||||
def test_captures_refresh_token_if_present_forward_compat(
|
||||
self, provider, rsa_keypair
|
||||
):
|
||||
"""Forward-compat: contract V1 doesn't issue, but if a future Portal
|
||||
does, we should preserve it in the Session for later use."""
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt-opaque",
|
||||
},
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
)
|
||||
assert session.refresh_token == "rt-opaque"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerifySession:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst123", portal_url="https://portal.example.com"
|
||||
)
|
||||
_patched_jwks(p, rsa_keypair)
|
||||
return p
|
||||
|
||||
def test_happy_path_returns_session(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert session.user_id == "usr_abc"
|
||||
assert session.org_id == "org_xyz"
|
||||
|
||||
def test_expired_token_returns_none(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, ttl_seconds=-1)
|
||||
assert provider.verify_session(access_token=token) is None
|
||||
|
||||
def test_wrong_audience_raises_provider_error(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, aud="agent:other-instance")
|
||||
with pytest.raises(ProviderError, match="verification failed"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_wrong_issuer_raises_provider_error(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, iss="https://evil.example")
|
||||
with pytest.raises(ProviderError, match="verification failed"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_verification_failure_message_surfaces_token_claims(
|
||||
self, provider, rsa_keypair
|
||||
):
|
||||
"""Operators need to see the actual iss/aud the token carries to debug
|
||||
config drift between HERMES_DASHBOARD_PORTAL_URL/CLIENT_ID and Portal."""
|
||||
token = _mint_token(rsa_keypair, iss="https://evil.example")
|
||||
with pytest.raises(ProviderError) as excinfo:
|
||||
provider.verify_session(access_token=token)
|
||||
msg = str(excinfo.value)
|
||||
# Both the observed (token) and expected (configured) values appear.
|
||||
assert "'https://evil.example'" in msg
|
||||
assert "'https://portal.example.com'" in msg # configured portal URL
|
||||
|
||||
def test_missing_sub_raises(self, provider, rsa_keypair):
|
||||
# PyJWT's "require" set includes sub, so this surfaces as
|
||||
# InvalidTokenError → ProviderError before we ever touch _session_from_claims.
|
||||
token = _mint_token(rsa_keypair, sub="")
|
||||
# Empty sub still encodes successfully; PyJWT's require check only
|
||||
# asserts presence. Our own _session_from_claims rejects empty.
|
||||
with pytest.raises(ProviderError, match="sub"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_agent_instance_id_mismatch_rejected(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, agent_instance_id="some-other-id")
|
||||
with pytest.raises(ProviderError, match="agent_instance_id mismatch"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_agent_instance_id_missing_is_tolerated(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, agent_instance_id=None)
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
|
||||
def test_contract_version_missing_warns_but_succeeds(
|
||||
self, provider, rsa_keypair, caplog
|
||||
):
|
||||
import logging
|
||||
token = _mint_token(rsa_keypair, oauth_contract_version=None)
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.dashboard_auth.nous"):
|
||||
session = provider.verify_session(access_token=token)
|
||||
assert session is not None
|
||||
assert any(
|
||||
"oauth_contract_version" in r.message for r in caplog.records
|
||||
)
|
||||
|
||||
def test_contract_version_mismatch_rejected(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair, oauth_contract_version=2)
|
||||
with pytest.raises(ProviderError, match="oauth_contract_version"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
def test_jwks_unreachable_raises_provider_error(self, provider, rsa_keypair):
|
||||
token = _mint_token(rsa_keypair)
|
||||
# Replace the patched client so it raises.
|
||||
bad_client = MagicMock()
|
||||
bad_client.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError(
|
||||
"fetch failed"
|
||||
)
|
||||
provider._jwks_client = bad_client
|
||||
with pytest.raises(ProviderError, match="JWKS"):
|
||||
provider.verify_session(access_token=token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_session + revoke_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshAndRevoke:
|
||||
@pytest.fixture
|
||||
def provider(self, rsa_keypair):
|
||||
p = nous_plugin.NousDashboardAuthProvider(
|
||||
client_id="agent:inst123", portal_url="https://portal.example.com"
|
||||
)
|
||||
_patched_jwks(p, rsa_keypair)
|
||||
return p
|
||||
|
||||
def _mock_post(self, status_code, body, *, ctype="application/json"):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
if isinstance(body, dict):
|
||||
resp.text = json.dumps(body)
|
||||
resp.json = MagicMock(return_value=body)
|
||||
else:
|
||||
resp.text = body
|
||||
resp.json = MagicMock(side_effect=ValueError("not json"))
|
||||
resp.headers = {"content-type": ctype}
|
||||
return resp
|
||||
|
||||
def test_refresh_happy_path_returns_rotated_session(self, provider, rsa_keypair):
|
||||
# Portal returns a fresh access token AND a rotated refresh token.
|
||||
access_token = _mint_token(rsa_keypair)
|
||||
mock_resp = self._mock_post(
|
||||
200,
|
||||
{
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": "rt_rotated_value",
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
session = provider.refresh_session(refresh_token="rt_old_value")
|
||||
|
||||
assert isinstance(session, Session)
|
||||
assert session.access_token == access_token
|
||||
# The ROTATED refresh token must be surfaced so the middleware can
|
||||
# persist it back to the cookie.
|
||||
assert session.refresh_token == "rt_rotated_value"
|
||||
assert session.provider == "nous"
|
||||
|
||||
# Posts grant_type=refresh_token with the RT in BOTH the body (Portal's
|
||||
# schema requires it there) and the X-Refresh-Token header (log
|
||||
# redaction). Verified against the live preview deploy.
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["data"]["grant_type"] == "refresh_token"
|
||||
assert kwargs["data"]["client_id"] == "agent:inst123"
|
||||
assert kwargs["data"]["refresh_token"] == "rt_old_value"
|
||||
assert kwargs["headers"]["x-nous-refresh-token"] == "rt_old_value"
|
||||
|
||||
def test_refresh_400_raises_refresh_expired(self, provider):
|
||||
# Expired / revoked / reuse-detected RT → Portal 400 → force re-login.
|
||||
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(RefreshExpiredError, match="invalid_grant"):
|
||||
provider.refresh_session(refresh_token="rt_dead")
|
||||
|
||||
def test_refresh_empty_token_raises_refresh_expired_without_network(self, provider):
|
||||
# No RT present — fail fast as a dead session, never hit the network.
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post") as mock_post:
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
provider.refresh_session(refresh_token="")
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_refresh_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post",
|
||||
side_effect=httpx.RequestError("boom"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
provider.refresh_session(refresh_token="rt_x")
|
||||
|
||||
def test_refresh_500_raises_provider_error(self, provider):
|
||||
mock_resp = self._mock_post(500, "oops", ctype="text/plain")
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError):
|
||||
provider.refresh_session(refresh_token="rt_x")
|
||||
|
||||
def test_revoke_is_noop(self, provider):
|
||||
# Must not raise; returns None implicitly.
|
||||
assert provider.revoke_session(refresh_token="anything") is None
|
||||
assert provider.revoke_session(refresh_token="") is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
"""Behavior-parity check for the image-gen FAL plugin migration (#26241).
|
||||
|
||||
Spawns one subprocess per (version, scenario) cell — pinned to either
|
||||
``origin/main`` (legacy in-tree FAL fall-through + ``configured == "fal"``
|
||||
skip in ``_dispatch_to_plugin_provider``) or this PR's worktree (FAL is
|
||||
itself a plugin and the dispatcher routes every set provider through
|
||||
the registry). Each subprocess clears all FAL-related env vars + writes
|
||||
a ``config.yaml``, then asks the dispatcher how it would route an
|
||||
``image_generate`` call. The emitted shape tuple is
|
||||
``{dispatch_kind, provider_name, model}``:
|
||||
|
||||
* ``dispatch_kind`` ∈ ``{"legacy_fal", "plugin", "error", None}`` —
|
||||
whether the call would go straight to the in-tree pipeline,
|
||||
through ``_dispatch_to_plugin_provider``, raise an explicit
|
||||
provider-not-registered error, or fall through silently.
|
||||
* ``provider_name`` — when ``dispatch_kind == "plugin"``, the
|
||||
resolved provider name. ``None`` otherwise.
|
||||
* ``model`` — the resolved FAL model id when applicable.
|
||||
|
||||
The parent process diffs the shapes per scenario. A diff means the
|
||||
migration introduced an observable behaviour change vs origin/main —
|
||||
likely a real regression for users on the existing config keys.
|
||||
|
||||
Run from the PR worktree:
|
||||
|
||||
python tests/plugins/image_gen/check_parity_vs_main.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
# Pin one path to current main, one to the PR worktree.
|
||||
# ``REPO_ROOT`` is ``.../.worktrees/<name>``; the main checkout lives
|
||||
# two levels up. When running directly from a regular clone (no
|
||||
# worktree), ``MAIN_DIR`` falls back to a sibling ``hermes-agent-main``
|
||||
# checkout if one exists.
|
||||
def _resolve_main_dir() -> Path:
|
||||
candidate = REPO_ROOT.parent.parent
|
||||
if (candidate / "tools" / "image_generation_tool.py").exists() and candidate != REPO_ROOT:
|
||||
return candidate
|
||||
sibling = REPO_ROOT.parent / "hermes-agent-main"
|
||||
if (sibling / "tools" / "image_generation_tool.py").exists():
|
||||
return sibling
|
||||
return REPO_ROOT
|
||||
|
||||
|
||||
MAIN_DIR = _resolve_main_dir()
|
||||
PR_DIR = REPO_ROOT
|
||||
assert (PR_DIR / "tools" / "image_generation_tool.py").exists(), (
|
||||
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
|
||||
)
|
||||
|
||||
|
||||
SUBPROCESS_SCRIPT = r"""
|
||||
import json, os, sys, tempfile
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
# Isolated HERMES_HOME so the config write is hermetic.
|
||||
home = tempfile.mkdtemp()
|
||||
os.environ["HERMES_HOME"] = home
|
||||
|
||||
# Clear FAL-related env so dispatch decisions are config-driven.
|
||||
for k in (
|
||||
"FAL_KEY", "FAL_QUEUE_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
|
||||
"FAL_IMAGE_MODEL",
|
||||
):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
scenario_env = json.loads(sys.argv[2])
|
||||
os.environ.update(scenario_env)
|
||||
|
||||
config_yaml = sys.argv[3]
|
||||
config_path = os.path.join(home, "config.yaml")
|
||||
with open(config_path, "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
# Fresh import — must not have anything cached.
|
||||
for name in list(sys.modules):
|
||||
if (name.startswith("tools.")
|
||||
or name.startswith("agent.")
|
||||
or name.startswith("plugins.")
|
||||
or name.startswith("hermes_cli.")):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
import tools.image_generation_tool as image_tool
|
||||
|
||||
dispatch_kind = None
|
||||
provider_name = None
|
||||
model = None
|
||||
error_text = None
|
||||
|
||||
try:
|
||||
raw = image_tool._dispatch_to_plugin_provider("ping", "landscape")
|
||||
if raw is None:
|
||||
dispatch_kind = "legacy_fal"
|
||||
else:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(parsed, dict):
|
||||
if parsed.get("error_type") == "provider_not_registered":
|
||||
dispatch_kind = "error"
|
||||
error_text = parsed.get("error")
|
||||
else:
|
||||
dispatch_kind = "plugin"
|
||||
provider_name = parsed.get("provider")
|
||||
model = parsed.get("model")
|
||||
else:
|
||||
dispatch_kind = "unknown_payload"
|
||||
|
||||
if model is None:
|
||||
# _resolve_fal_model still returns the active FAL model id even
|
||||
# when dispatch goes to a non-FAL plugin — used for the diff
|
||||
# only when applicable.
|
||||
try:
|
||||
model_id, _meta = image_tool._resolve_fal_model()
|
||||
if dispatch_kind == "legacy_fal":
|
||||
model = model_id
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
dispatch_kind = "exception"
|
||||
error_text = repr(exc)
|
||||
|
||||
shape = {
|
||||
"dispatch_kind": dispatch_kind,
|
||||
"provider_name": provider_name,
|
||||
"model": model,
|
||||
"error_present": error_text is not None,
|
||||
}
|
||||
print(json.dumps(shape))
|
||||
"""
|
||||
|
||||
|
||||
SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
|
||||
# (label, config.yaml body, extra env vars)
|
||||
("no-config-no-env", "", {}),
|
||||
(
|
||||
"explicit-fal-no-creds",
|
||||
"image_gen:\n provider: fal\n",
|
||||
{},
|
||||
),
|
||||
(
|
||||
"explicit-fal-with-creds",
|
||||
"image_gen:\n provider: fal\n",
|
||||
{"FAL_KEY": "test-key"},
|
||||
),
|
||||
(
|
||||
"explicit-fal-with-model",
|
||||
"image_gen:\n provider: fal\n model: fal-ai/flux-2-pro\n",
|
||||
{"FAL_KEY": "test-key"},
|
||||
),
|
||||
(
|
||||
"explicit-typo-provider",
|
||||
"image_gen:\n provider: not-a-real-backend\n",
|
||||
{"FAL_KEY": "test-key"},
|
||||
),
|
||||
(
|
||||
"managed-gateway-only",
|
||||
"",
|
||||
{
|
||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
|
||||
venv_python = repo_path / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = Path("python3")
|
||||
|
||||
out = subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
SUBPROCESS_SCRIPT,
|
||||
str(repo_path),
|
||||
json.dumps(env),
|
||||
config_yaml,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return {
|
||||
"error": "subprocess failed",
|
||||
"stdout": out.stdout[-500:],
|
||||
"stderr": out.stderr[-500:],
|
||||
}
|
||||
try:
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
except Exception as exc:
|
||||
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
|
||||
|
||||
|
||||
def _reduce(shape: dict) -> dict:
|
||||
"""Reduce to the parts that matter for user-visible parity.
|
||||
|
||||
On origin/main, ``explicit-fal-*`` scenarios short-circuit to
|
||||
``legacy_fal`` because of the ``configured == "fal"`` skip. On the
|
||||
PR, those same scenarios route through the plugin and emit
|
||||
``dispatch_kind == "plugin"`` with ``provider_name == "fal"``.
|
||||
|
||||
Both shapes are functionally equivalent — the plugin's ``generate()``
|
||||
re-enters the same in-tree pipeline via ``_it`` indirection — but
|
||||
we want the diff to be visible so reviewers can sign off on the
|
||||
intentional behaviour delta.
|
||||
"""
|
||||
return {
|
||||
"dispatch_kind": shape.get("dispatch_kind"),
|
||||
"provider_name": shape.get("provider_name"),
|
||||
"model": shape.get("model"),
|
||||
"error_present": shape.get("error_present"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"main: {MAIN_DIR}")
|
||||
print(f"pr: {PR_DIR}")
|
||||
print()
|
||||
|
||||
if MAIN_DIR == PR_DIR:
|
||||
print(
|
||||
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
|
||||
" Set up a sibling 'hermes-agent-main' checkout pinned to "
|
||||
"origin/main to get real parity coverage."
|
||||
)
|
||||
print()
|
||||
|
||||
failures: list[str] = []
|
||||
errors: list[str] = []
|
||||
intentional_diffs: list[tuple[str, dict, dict]] = []
|
||||
for label, config_yaml, env in SCENARIOS:
|
||||
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
|
||||
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
|
||||
|
||||
if "error" in main_shape or "error" in pr_shape:
|
||||
print(f" [ERR ] {label}: subprocess failed")
|
||||
print(f" main: {main_shape}")
|
||||
print(f" pr: {pr_shape}")
|
||||
errors.append(label)
|
||||
continue
|
||||
|
||||
main_reduced = _reduce(main_shape)
|
||||
pr_reduced = _reduce(pr_shape)
|
||||
|
||||
if main_reduced == pr_reduced:
|
||||
print(f" [OK] {label}: {main_reduced}")
|
||||
continue
|
||||
|
||||
# On main, "explicit-fal-*" returns legacy_fal; on PR, plugin
|
||||
# dispatch. That's the only acceptable diff — flag everything
|
||||
# else as a regression.
|
||||
legacy_to_plugin_fal = (
|
||||
main_reduced.get("dispatch_kind") == "legacy_fal"
|
||||
and pr_reduced.get("dispatch_kind") == "plugin"
|
||||
and pr_reduced.get("provider_name") == "fal"
|
||||
)
|
||||
if legacy_to_plugin_fal:
|
||||
print(f" [DIFF] {label}: legacy_fal → plugin (fal) — expected")
|
||||
intentional_diffs.append((label, main_reduced, pr_reduced))
|
||||
else:
|
||||
print(f" [FAIL] {label}")
|
||||
print(f" main: {main_reduced}")
|
||||
print(f" pr: {pr_reduced}")
|
||||
failures.append(label)
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
if failures:
|
||||
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
if intentional_diffs:
|
||||
print(
|
||||
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
|
||||
f"legacy_fal → plugin dispatch for explicit FAL paths."
|
||||
)
|
||||
if failures or errors:
|
||||
return 1
|
||||
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the FAL.ai image generation plugin.
|
||||
|
||||
The plugin is a thin registration adapter — actual FAL pipeline logic
|
||||
lives in ``tools.image_generation_tool`` and is exercised by
|
||||
``tests/tools/test_image_generation.py``. These tests focus on:
|
||||
|
||||
* the ``ImageGenProvider`` ABC surface (name, models, schema)
|
||||
* call-time indirection (``_it`` resolution at ``generate()`` time so
|
||||
``monkeypatch.setattr(image_tool, ...)`` keeps working)
|
||||
* response shape stamping (provider/prompt/aspect_ratio/model)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFalImageGenProviderSurface:
|
||||
def test_name(self):
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
assert FalImageGenProvider().name == "fal"
|
||||
|
||||
def test_display_name(self):
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
assert FalImageGenProvider().display_name == "FAL.ai"
|
||||
|
||||
def test_default_model_matches_legacy(self):
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
from tools.image_generation_tool import DEFAULT_MODEL
|
||||
|
||||
assert FalImageGenProvider().default_model() == DEFAULT_MODEL
|
||||
|
||||
def test_list_models_uses_legacy_catalog(self):
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
from tools.image_generation_tool import FAL_MODELS
|
||||
|
||||
provider = FalImageGenProvider()
|
||||
models = provider.list_models()
|
||||
ids = {m["id"] for m in models}
|
||||
# Whatever FAL_MODELS ships, the provider mirrors verbatim.
|
||||
assert ids == set(FAL_MODELS.keys())
|
||||
# Spot-check the expected first-class fields are present.
|
||||
for entry in models:
|
||||
for field in ("id", "display", "speed", "strengths", "price"):
|
||||
assert field in entry
|
||||
|
||||
def test_setup_schema_advertises_fal_key(self):
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
schema = FalImageGenProvider().get_setup_schema()
|
||||
assert schema["name"] == "FAL.ai"
|
||||
assert schema["badge"] == "paid"
|
||||
env_keys = {entry["key"] for entry in schema.get("env_vars", [])}
|
||||
assert "FAL_KEY" in env_keys
|
||||
|
||||
|
||||
class TestFalImageGenProviderAvailability:
|
||||
def test_is_available_when_legacy_check_passes(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
monkeypatch.setattr(image_tool, "check_fal_api_key", lambda: True)
|
||||
assert FalImageGenProvider().is_available() is True
|
||||
|
||||
def test_is_available_false_when_legacy_check_fails(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
monkeypatch.setattr(image_tool, "check_fal_api_key", lambda: False)
|
||||
assert FalImageGenProvider().is_available() is False
|
||||
|
||||
def test_is_available_handles_legacy_exception(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("config broke")
|
||||
|
||||
monkeypatch.setattr(image_tool, "check_fal_api_key", _boom)
|
||||
# Picker must not propagate exceptions — show as "not available".
|
||||
assert FalImageGenProvider().is_available() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate() — call-time indirection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFalImageGenProviderGenerate:
|
||||
def test_generate_delegates_to_legacy_image_generate_tool(self, monkeypatch):
|
||||
"""Plugin must look up ``image_generate_tool`` at call time so
|
||||
``monkeypatch.setattr(image_tool, "image_generate_tool", ...)``
|
||||
takes effect."""
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_image_generate_tool(prompt, aspect_ratio, **kwargs):
|
||||
captured["prompt"] = prompt
|
||||
captured["aspect_ratio"] = aspect_ratio
|
||||
captured["kwargs"] = kwargs
|
||||
return json.dumps({"success": True, "image": "https://fake/image.png"})
|
||||
|
||||
monkeypatch.setattr(image_tool, "image_generate_tool", fake_image_generate_tool)
|
||||
monkeypatch.setattr(image_tool, "_resolve_fal_model",
|
||||
lambda: ("fal-ai/flux-2/klein/9b", {}))
|
||||
|
||||
result = FalImageGenProvider().generate(
|
||||
"a serene mountain landscape",
|
||||
aspect_ratio="square",
|
||||
seed=42,
|
||||
)
|
||||
|
||||
assert captured["prompt"] == "a serene mountain landscape"
|
||||
assert captured["aspect_ratio"] == "square"
|
||||
assert captured["kwargs"] == {"seed": 42}
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "https://fake/image.png"
|
||||
# Stamped fields for the unified response shape
|
||||
assert result["provider"] == "fal"
|
||||
assert result["prompt"] == "a serene mountain landscape"
|
||||
assert result["aspect_ratio"] == "square"
|
||||
assert result["model"] == "fal-ai/flux-2/klein/9b"
|
||||
|
||||
def test_generate_invalid_aspect_ratio_is_coerced(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
seen_aspect = {}
|
||||
|
||||
def fake(prompt, aspect_ratio, **kwargs):
|
||||
seen_aspect["v"] = aspect_ratio
|
||||
return json.dumps({"success": True, "image": "x"})
|
||||
|
||||
monkeypatch.setattr(image_tool, "image_generate_tool", fake)
|
||||
monkeypatch.setattr(image_tool, "_resolve_fal_model",
|
||||
lambda: ("fal-ai/flux-2/klein/9b", {}))
|
||||
|
||||
FalImageGenProvider().generate("p", aspect_ratio="not-a-real-ratio")
|
||||
# ``resolve_aspect_ratio`` clamps to landscape.
|
||||
assert seen_aspect["v"] == "landscape"
|
||||
|
||||
def test_generate_passthrough_drops_none_kwargs(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake(prompt, aspect_ratio, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return json.dumps({"success": True, "image": "x"})
|
||||
|
||||
monkeypatch.setattr(image_tool, "image_generate_tool", fake)
|
||||
monkeypatch.setattr(image_tool, "_resolve_fal_model",
|
||||
lambda: ("fal-ai/flux-2/klein/9b", {}))
|
||||
|
||||
FalImageGenProvider().generate(
|
||||
"p",
|
||||
aspect_ratio="landscape",
|
||||
seed=None,
|
||||
num_images=2,
|
||||
guidance_scale=None,
|
||||
)
|
||||
|
||||
# ``None`` values must not be forwarded — they'd override the
|
||||
# model's defaults inside the legacy payload builder.
|
||||
assert "seed" not in seen
|
||||
assert "guidance_scale" not in seen
|
||||
assert seen.get("num_images") == 2
|
||||
|
||||
def test_generate_catches_exception_from_legacy(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError("FAL endpoint exploded")
|
||||
|
||||
monkeypatch.setattr(image_tool, "image_generate_tool", boom)
|
||||
|
||||
result = FalImageGenProvider().generate("p")
|
||||
assert result["success"] is False
|
||||
assert "FAL image generation failed" in result["error"]
|
||||
assert result["error_type"] == "RuntimeError"
|
||||
assert result["provider"] == "fal"
|
||||
|
||||
def test_generate_invalid_json_response(self, monkeypatch):
|
||||
import tools.image_generation_tool as image_tool
|
||||
from plugins.image_gen.fal import FalImageGenProvider
|
||||
|
||||
monkeypatch.setattr(image_tool, "image_generate_tool", lambda **kw: "not-json")
|
||||
monkeypatch.setattr(image_tool, "_resolve_fal_model",
|
||||
lambda: ("fal-ai/flux-2/klein/9b", {}))
|
||||
|
||||
result = FalImageGenProvider().generate("p")
|
||||
assert result["success"] is False
|
||||
assert "Invalid JSON" in result["error"]
|
||||
assert result["provider"] == "fal"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFalImageGenPluginRegistration:
|
||||
def test_register_wires_provider_into_registry(self):
|
||||
from plugins.image_gen.fal import FalImageGenProvider, register
|
||||
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
|
||||
ctx.register_image_gen_provider.assert_called_once()
|
||||
(registered,), _ = ctx.register_image_gen_provider.call_args
|
||||
assert isinstance(registered, FalImageGenProvider)
|
||||
@@ -0,0 +1,848 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for Krea image generation provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_api_key(monkeypatch):
|
||||
"""Ensure KREA_API_KEY is set for all tests."""
|
||||
monkeypatch.setenv("KREA_API_KEY", "test-key-12345")
|
||||
|
||||
|
||||
def _completed_job(url: str = "https://krea.cdn/img.png") -> dict:
|
||||
return {
|
||||
"job_id": "00000000-0000-0000-0000-000000000abc",
|
||||
"status": "completed",
|
||||
"created_at": "2026-05-27T00:00:00Z",
|
||||
"completed_at": "2026-05-27T00:00:30Z",
|
||||
"result": {"urls": [url]},
|
||||
}
|
||||
|
||||
|
||||
def _submit_response(job_id: str = "00000000-0000-0000-0000-000000000abc"):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"job_id": job_id,
|
||||
"status": "queued",
|
||||
"created_at": "2026-05-27T00:00:00Z",
|
||||
"completed_at": None,
|
||||
"result": None,
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _poll_response(body: dict):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider class tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKreaImageGenProvider:
|
||||
def test_name(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
assert KreaImageGenProvider().name == "krea"
|
||||
|
||||
def test_display_name(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
assert KreaImageGenProvider().display_name == "Krea"
|
||||
|
||||
def test_is_available_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KREA_API_KEY", "sk-test")
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
assert KreaImageGenProvider().is_available() is True
|
||||
|
||||
def test_is_available_without_key(self, monkeypatch):
|
||||
monkeypatch.delenv("KREA_API_KEY", raising=False)
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
# No direct key AND no managed gateway → unavailable.
|
||||
monkeypatch.setattr(krea_mod, "_managed_krea_gateway_ready", lambda: False)
|
||||
assert KreaImageGenProvider().is_available() is False
|
||||
|
||||
def test_is_available_via_managed_gateway_without_key(self, monkeypatch):
|
||||
monkeypatch.delenv("KREA_API_KEY", raising=False)
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
# No direct key but the managed Nous gateway is ready → available.
|
||||
monkeypatch.setattr(krea_mod, "_managed_krea_gateway_ready", lambda: True)
|
||||
assert KreaImageGenProvider().is_available() is True
|
||||
|
||||
def test_list_models(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
models = KreaImageGenProvider().list_models()
|
||||
ids = {m["id"] for m in models}
|
||||
assert {"krea-2-medium", "krea-2-large"} <= ids
|
||||
# Each entry carries the picker fields the registry expects.
|
||||
for m in models:
|
||||
assert m["display"]
|
||||
assert m["speed"]
|
||||
assert m["strengths"]
|
||||
assert m["price"]
|
||||
|
||||
def test_default_model_is_medium(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
assert KreaImageGenProvider().default_model() == "krea-2-medium"
|
||||
|
||||
def test_get_setup_schema(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
schema = KreaImageGenProvider().get_setup_schema()
|
||||
assert schema["name"] == "Krea"
|
||||
assert schema["badge"] == "paid"
|
||||
env_vars = schema["env_vars"]
|
||||
assert len(env_vars) == 1
|
||||
assert env_vars[0]["key"] == "KREA_API_KEY"
|
||||
assert "krea.ai" in env_vars[0]["url"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelResolution:
|
||||
def test_default(self):
|
||||
from plugins.image_gen.krea import _resolve_model
|
||||
|
||||
model_id, meta = _resolve_model()
|
||||
assert model_id == "krea-2-medium"
|
||||
assert meta["path"] == "medium"
|
||||
|
||||
def test_env_override_large(self, monkeypatch):
|
||||
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-large")
|
||||
from plugins.image_gen.krea import _resolve_model
|
||||
|
||||
model_id, meta = _resolve_model()
|
||||
assert model_id == "krea-2-large"
|
||||
assert meta["path"] == "large"
|
||||
|
||||
def test_env_override_unknown_falls_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-xxl-fake")
|
||||
from plugins.image_gen.krea import _resolve_model
|
||||
|
||||
model_id, _ = _resolve_model()
|
||||
assert model_id == "krea-2-medium"
|
||||
|
||||
def test_creativity_default(self):
|
||||
from plugins.image_gen.krea import _resolve_creativity
|
||||
|
||||
assert _resolve_creativity(None) == "medium"
|
||||
|
||||
def test_creativity_valid(self):
|
||||
from plugins.image_gen.krea import _resolve_creativity
|
||||
|
||||
assert _resolve_creativity("HIGH") == "high"
|
||||
assert _resolve_creativity(" raw ") == "raw"
|
||||
|
||||
def test_creativity_invalid(self):
|
||||
from plugins.image_gen.krea import _resolve_creativity
|
||||
|
||||
assert _resolve_creativity("ultra") == "medium"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate — main flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_missing_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("KREA_API_KEY", raising=False)
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
assert result["success"] is False
|
||||
assert "KREA_API_KEY" in result["error"]
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
def test_empty_prompt(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
result = KreaImageGenProvider().generate(prompt=" ")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_argument"
|
||||
|
||||
def test_successful_generation(self):
|
||||
"""Happy path: submit → one poll → completed → URL downloaded."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job("https://krea.cdn/result.png"))
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll) as mock_get, \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/krea_krea-2-medium_test.png"),
|
||||
) as mock_save, \
|
||||
patch("plugins.image_gen.krea.time.sleep"): # skip real waits
|
||||
result = KreaImageGenProvider().generate(prompt="A cinematic lamp")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "/tmp/krea_krea-2-medium_test.png"
|
||||
assert result["provider"] == "krea"
|
||||
assert result["model"] == "krea-2-medium"
|
||||
assert result["aspect_ratio"] == "landscape"
|
||||
assert result["job_id"] == "00000000-0000-0000-0000-000000000abc"
|
||||
assert result["resolution"] == "1K"
|
||||
assert result["creativity"] == "medium"
|
||||
# Submit hit the medium endpoint
|
||||
post_url = mock_post.call_args[0][0]
|
||||
assert post_url.endswith("/generate/image/krea/krea-2/medium")
|
||||
# Poll hit /jobs/{job_id}
|
||||
poll_url = mock_get.call_args[0][0]
|
||||
assert "/jobs/00000000-0000-0000-0000-000000000abc" in poll_url
|
||||
# URL was materialised once
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_large_model_routes_to_large_endpoint(self, monkeypatch):
|
||||
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-large")
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
post_url = mock_post.call_args[0][0]
|
||||
assert post_url.endswith("/generate/image/krea/krea-2/large")
|
||||
|
||||
def test_aspect_ratio_mapping(self):
|
||||
"""Hermes 'square' must map to Krea '1:1' in the wire payload."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
KreaImageGenProvider().generate(prompt="test", aspect_ratio="square")
|
||||
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["aspect_ratio"] == "1:1"
|
||||
assert payload["resolution"] == "1K"
|
||||
|
||||
def test_auth_header(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer test-key-12345"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
def test_passthrough_seed_styles_moodboards(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
KreaImageGenProvider().generate(
|
||||
prompt="test",
|
||||
seed=42,
|
||||
styles=[{"id": "lora-1", "strength": 0.7}],
|
||||
moodboards=[{"url": "https://x.com/mood.png"}, {"url": "https://x.com/mood2.png"}],
|
||||
image_style_references=[{"url": f"https://x.com/{i}.png"} for i in range(15)],
|
||||
creativity="high",
|
||||
)
|
||||
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["seed"] == 42
|
||||
assert payload["styles"] == [{"id": "lora-1", "strength": 0.7}]
|
||||
assert len(payload["moodboards"]) == 1 # capped at 1
|
||||
assert len(payload["image_style_references"]) == 10 # capped at 10
|
||||
assert payload["creativity"] == "high"
|
||||
|
||||
def test_string_style_references_converted_to_objects(self):
|
||||
"""Krea requires {url, strength} objects; bare URL strings must be
|
||||
converted (a string yields a 422 'Expected object, received string')."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
KreaImageGenProvider().generate(
|
||||
prompt="test",
|
||||
image_style_references=[
|
||||
"https://x.com/a.png",
|
||||
{"url": "https://x.com/b.png", "strength": 1.2},
|
||||
],
|
||||
)
|
||||
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["image_style_references"] == [
|
||||
{"url": "https://x.com/a.png", "strength": 0.6},
|
||||
{"url": "https://x.com/b.png", "strength": 1.2},
|
||||
]
|
||||
|
||||
def test_unknown_kwargs_ignored(self):
|
||||
"""Forward-compat: unknown kwargs must not break generate()."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(
|
||||
prompt="test",
|
||||
fictional_param="should be ignored",
|
||||
num_images=4,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate — error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerateErrors:
|
||||
def test_submit_http_error(self):
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
resp = req_lib.Response()
|
||||
resp.status_code = 401
|
||||
resp._content = b'{"error": {"message": "Invalid API key"}}'
|
||||
resp.headers["Content-Type"] = "application/json"
|
||||
resp.raise_for_status = MagicMock(
|
||||
side_effect=req_lib.HTTPError(response=resp)
|
||||
)
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "401" in result["error"]
|
||||
assert "Invalid API key" in result["error"]
|
||||
|
||||
def test_submit_timeout(self):
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
with patch(
|
||||
"plugins.image_gen.krea.requests.post", side_effect=req_lib.Timeout()
|
||||
):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "timeout"
|
||||
|
||||
def test_submit_connection_error(self):
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
with patch(
|
||||
"plugins.image_gen.krea.requests.post",
|
||||
side_effect=req_lib.ConnectionError("dns nope"),
|
||||
):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "connection_error"
|
||||
|
||||
def test_submit_missing_job_id(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
bad_submit = MagicMock()
|
||||
bad_submit.status_code = 200
|
||||
bad_submit.raise_for_status = MagicMock()
|
||||
bad_submit.json.return_value = {"status": "queued"}
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=bad_submit):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_response"
|
||||
assert "job_id" in result["error"]
|
||||
|
||||
def test_job_failed(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
failed = {
|
||||
"job_id": "abc",
|
||||
"status": "failed",
|
||||
"completed_at": "2026-05-27T00:01:00Z",
|
||||
"result": {"error": "NSFW content"},
|
||||
}
|
||||
|
||||
submit = _submit_response()
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.requests.get",
|
||||
return_value=_poll_response(failed),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "NSFW" in result["error"]
|
||||
|
||||
def test_job_cancelled(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
cancelled = {
|
||||
"job_id": "abc",
|
||||
"status": "cancelled",
|
||||
"completed_at": "2026-05-27T00:01:00Z",
|
||||
"result": {},
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.requests.get",
|
||||
return_value=_poll_response(cancelled),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "cancelled"
|
||||
|
||||
def test_completed_but_missing_urls(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
completed_empty = {
|
||||
"job_id": "abc",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-05-27T00:01:00Z",
|
||||
"result": {"urls": []},
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.requests.get",
|
||||
return_value=_poll_response(completed_empty),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_url_download_failure_falls_back_to_bare_url(self):
|
||||
"""Mirror of xAI behaviour — if local cache fails, return the URL."""
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
url = "https://krea.cdn/expired-soon.png"
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job(url))
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
side_effect=req_lib.HTTPError("404"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == url
|
||||
|
||||
def test_polling_picks_up_completed_at_with_unknown_status(self):
|
||||
"""``completed_at`` set + unrecognised pending status → still terminal."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
# Use a status value that is NOT in our terminal set ("intermediate-complete")
|
||||
# but with completed_at populated — Krea's spec says completed_at is the
|
||||
# canonical terminal marker.
|
||||
oddball = {
|
||||
"job_id": "abc",
|
||||
"status": "intermediate-complete",
|
||||
"completed_at": "2026-05-27T00:01:00Z",
|
||||
"result": {"urls": ["https://krea.cdn/done.png"]},
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.requests.get",
|
||||
return_value=_poll_response(oddball),
|
||||
), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestPollRetryPolicy:
|
||||
"""Polling fail-fast on permanent 4xx, retry on transient 5xx/429."""
|
||||
|
||||
def _http_error_response(self, status: int):
|
||||
import requests as req_lib
|
||||
|
||||
resp = req_lib.Response()
|
||||
resp.status_code = status
|
||||
resp._content = b'{"error": "boom"}'
|
||||
resp.headers["Content-Type"] = "application/json"
|
||||
resp.raise_for_status = MagicMock(
|
||||
side_effect=req_lib.HTTPError(response=resp)
|
||||
)
|
||||
return resp
|
||||
|
||||
def test_poll_fails_fast_on_401(self):
|
||||
"""Auth failure mid-poll should not wait the 180s deadline."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
bad_poll = self._http_error_response(401)
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "401" in result["error"]
|
||||
# One call — no retry on permanent auth failure.
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_poll_fails_fast_on_404(self):
|
||||
"""Missing job (404) should surface immediately, not retry for 180s."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
bad_poll = self._http_error_response(404)
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "404" in result["error"]
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_poll_fails_fast_on_403(self):
|
||||
"""Billing/permission failure (403) should not retry."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
bad_poll = self._http_error_response(403)
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_poll_retries_on_503_then_succeeds(self):
|
||||
"""Transient 5xx should retry and eventually surface a completion."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
flaky = self._http_error_response(503)
|
||||
good = _poll_response(_completed_job("https://krea.cdn/ok.png"))
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.requests.get",
|
||||
side_effect=[flaky, flaky, good],
|
||||
) as mock_get, \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is True
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
def test_poll_retries_on_429(self):
|
||||
"""Rate-limit (429) is in the retryable set."""
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
rate_limited = self._http_error_response(429)
|
||||
good = _poll_response(_completed_job("https://krea.cdn/ok.png"))
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.requests.get",
|
||||
side_effect=[rate_limited, good],
|
||||
) as mock_get, \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is True
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed Nous gateway path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _managed_cfg(
|
||||
origin: str = "https://krea-gateway.example.com",
|
||||
token: str = "nous-tok-abc",
|
||||
):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(
|
||||
vendor="krea",
|
||||
gateway_origin=origin,
|
||||
nous_user_token=token,
|
||||
managed_mode=True,
|
||||
)
|
||||
|
||||
|
||||
class TestManagedGateway:
|
||||
def test_managed_submit_uses_gateway_origin_and_nous_token(self, monkeypatch):
|
||||
"""Managed mode submits to the gateway origin with the Nous token."""
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
# Even with a direct key present, an active managed gateway wins.
|
||||
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll) as mock_get, \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="A managed lamp")
|
||||
|
||||
assert result["success"] is True
|
||||
post_url = mock_post.call_args[0][0]
|
||||
assert post_url == (
|
||||
"https://krea-gateway.example.com/generate/image/krea/krea-2/medium"
|
||||
)
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer nous-tok-abc"
|
||||
# Idempotency key drives the gateway's per-generation billing boundary.
|
||||
assert headers["x-idempotency-key"]
|
||||
# Poll is bound to the same gateway + Nous token.
|
||||
poll_url = mock_get.call_args[0][0]
|
||||
assert poll_url.startswith("https://krea-gateway.example.com/jobs/")
|
||||
poll_headers = mock_get.call_args.kwargs["headers"]
|
||||
assert poll_headers["Authorization"] == "Bearer nous-tok-abc"
|
||||
|
||||
def test_managed_available_without_direct_key(self, monkeypatch):
|
||||
"""No KREA_API_KEY but an active gateway → generate proceeds (no auth_required)."""
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
monkeypatch.delenv("KREA_API_KEY", raising=False)
|
||||
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
def test_managed_4xx_returns_actionable_remediation(self, monkeypatch):
|
||||
import requests as req_lib
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
|
||||
|
||||
resp = req_lib.Response()
|
||||
resp.status_code = 402
|
||||
resp._content = b'{"error": {"message": "out of credits"}}'
|
||||
resp.headers["Content-Type"] = "application/json"
|
||||
resp.raise_for_status = MagicMock(side_effect=req_lib.HTTPError(response=resp))
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "402" in result["error"]
|
||||
assert "Nous Subscription Krea gateway" in result["error"]
|
||||
assert "KREA_API_KEY" in result["error"]
|
||||
|
||||
def test_managed_429_concurrency_hint(self, monkeypatch):
|
||||
import requests as req_lib
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
|
||||
|
||||
resp = req_lib.Response()
|
||||
resp.status_code = 429
|
||||
resp._content = b'{"error": {"message": "maximum number of concurrent jobs"}}'
|
||||
resp.headers["Content-Type"] = "application/json"
|
||||
resp.raise_for_status = MagicMock(side_effect=req_lib.HTTPError(response=resp))
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
|
||||
result = KreaImageGenProvider().generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "429" in result["error"]
|
||||
assert "concurrency" in result["error"].lower()
|
||||
|
||||
def test_managed_blocks_styles(self, monkeypatch):
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post") as mock_post:
|
||||
result = KreaImageGenProvider().generate(
|
||||
prompt="test",
|
||||
styles=[{"id": "lora-1"}],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "unsupported_argument"
|
||||
assert "LoRA" in result["error"] or "styles" in result["error"]
|
||||
# Never hit the network with an unsupported tier.
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_managed_blocks_moodboards(self, monkeypatch):
|
||||
import plugins.image_gen.krea as krea_mod
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
|
||||
|
||||
with patch("plugins.image_gen.krea.requests.post") as mock_post:
|
||||
result = KreaImageGenProvider().generate(
|
||||
prompt="test",
|
||||
moodboards=[{"url": "https://x.com/m.png"}],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "unsupported_argument"
|
||||
assert "moodboard" in result["error"].lower()
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
class TestExplicitModelOverride:
|
||||
def test_model_kwarg_overrides_config(self, monkeypatch):
|
||||
"""An explicit ``model`` kwarg (managed routing) wins over config/default."""
|
||||
from plugins.image_gen.krea import _resolve_model
|
||||
|
||||
model_id, meta = _resolve_model("krea-2-large")
|
||||
assert model_id == "krea-2-large"
|
||||
assert meta["path"] == "large"
|
||||
|
||||
def test_turbo_routes_to_medium_turbo_endpoint(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider
|
||||
|
||||
submit = _submit_response()
|
||||
poll = _poll_response(_completed_job())
|
||||
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
|
||||
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
|
||||
patch(
|
||||
"plugins.image_gen.krea.save_url_image",
|
||||
return_value=Path("/tmp/x.png"),
|
||||
), \
|
||||
patch("plugins.image_gen.krea.time.sleep"):
|
||||
result = KreaImageGenProvider().generate(prompt="test", model="krea-2-medium-turbo")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["model"] == "krea-2-medium-turbo"
|
||||
post_url = mock_post.call_args[0][0]
|
||||
assert post_url.endswith("/generate/image/krea/krea-2/medium-turbo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_register(self):
|
||||
from plugins.image_gen.krea import KreaImageGenProvider, register
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
register(mock_ctx)
|
||||
mock_ctx.register_image_gen_provider.assert_called_once()
|
||||
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
|
||||
assert isinstance(provider, KreaImageGenProvider)
|
||||
assert provider.name == "krea"
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Tests for the bundled ``openai-codex`` image_gen plugin.
|
||||
|
||||
Mirrors ``test_openai_provider.py`` but targets the standalone
|
||||
Codex/ChatGPT-OAuth-backed provider that uses the Responses
|
||||
``image_generation`` tool path instead of the ``images.generate`` REST
|
||||
endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# The plugin directory uses a hyphen, which is not a valid Python identifier
|
||||
# for the dotted-import form. Load it via importlib so tests don't need to
|
||||
# touch sys.path or rename the directory.
|
||||
codex_plugin = importlib.import_module("plugins.image_gen.openai-codex")
|
||||
|
||||
|
||||
# 1×1 transparent PNG — valid bytes for save_b64_image()
|
||||
_PNG_HEX = (
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
|
||||
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
|
||||
"ae426082"
|
||||
)
|
||||
|
||||
|
||||
def _b64_png() -> str:
|
||||
import base64
|
||||
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch):
|
||||
# Codex plugin is API-key-independent; clear it to make the test honest.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
return codex_plugin.OpenAICodexImageGenProvider()
|
||||
|
||||
|
||||
# ── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetadata:
|
||||
def test_name(self, provider):
|
||||
assert provider.name == "openai-codex"
|
||||
|
||||
def test_display_name(self, provider):
|
||||
assert provider.display_name == "OpenAI (Codex auth)"
|
||||
|
||||
def test_default_model(self, provider):
|
||||
assert provider.default_model() == "gpt-image-2-medium"
|
||||
|
||||
def test_list_models_three_tiers(self, provider):
|
||||
ids = [m["id"] for m in provider.list_models()]
|
||||
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
|
||||
|
||||
def test_setup_schema_has_no_required_env_vars(self, provider):
|
||||
schema = provider.get_setup_schema()
|
||||
assert schema["env_vars"] == []
|
||||
assert schema["badge"] == "free"
|
||||
|
||||
|
||||
# ── Availability ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAvailability:
|
||||
def test_unavailable_without_codex_token(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
|
||||
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
|
||||
|
||||
def test_available_with_codex_token(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
|
||||
|
||||
def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
|
||||
# Codex plugin is intentionally orthogonal to the API-key plugin —
|
||||
# the API key alone must NOT make it appear available.
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
|
||||
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_returns_auth_error_without_codex_token(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
def test_returns_invalid_argument_for_empty_prompt(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
result = provider.generate(" ")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_argument"
|
||||
|
||||
def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: _b64_png())
|
||||
|
||||
result = provider.generate("a cat", aspect_ratio="landscape")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["model"] == "gpt-image-2-medium"
|
||||
assert result["provider"] == "openai-codex"
|
||||
assert result["quality"] == "medium"
|
||||
|
||||
saved = Path(result["image"])
|
||||
assert saved.exists()
|
||||
assert saved.parent == tmp_path / "cache" / "images"
|
||||
# Filename prefix differs from the API-key plugin so cache audits can
|
||||
# tell the two backends apart.
|
||||
assert saved.name.startswith("openai_codex_")
|
||||
|
||||
def test_codex_stream_request_shape(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
captured = {}
|
||||
|
||||
def _collect(token, *, prompt, size, quality, input_images=None):
|
||||
captured.update(codex_plugin._build_responses_payload(
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
input_images=input_images,
|
||||
))
|
||||
return _b64_png()
|
||||
|
||||
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
|
||||
|
||||
result = provider.generate("a cat", aspect_ratio="portrait")
|
||||
assert result["success"] is True
|
||||
|
||||
assert captured["model"] == "gpt-5.5"
|
||||
assert captured["store"] is False
|
||||
assert captured["input"][0]["type"] == "message"
|
||||
assert captured["input"][0]["role"] == "user"
|
||||
assert captured["input"][0]["content"][0]["type"] == "input_text"
|
||||
assert captured["tool_choice"]["type"] == "allowed_tools"
|
||||
assert captured["tool_choice"]["mode"] == "required"
|
||||
assert captured["tool_choice"]["tools"] == [{"type": "image_generation"}]
|
||||
|
||||
tool = captured["tools"][0]
|
||||
assert tool["type"] == "image_generation"
|
||||
assert tool["model"] == "gpt-image-2"
|
||||
assert tool["quality"] == "medium"
|
||||
assert tool["size"] == "1024x1536"
|
||||
assert tool["output_format"] == "png"
|
||||
assert tool["background"] == "opaque"
|
||||
assert tool["partial_images"] == 1
|
||||
|
||||
def test_capabilities_advertise_image_inputs(self, provider):
|
||||
caps = provider.capabilities()
|
||||
assert caps["modalities"] == ["text", "image"]
|
||||
assert caps["max_reference_images"] == 16
|
||||
|
||||
def test_codex_stream_request_includes_source_images(self, provider, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
image_path = tmp_path / "source.png"
|
||||
image_path.write_bytes(bytes.fromhex(_PNG_HEX))
|
||||
|
||||
captured = {}
|
||||
|
||||
def _collect(token, *, prompt, size, quality, input_images=None):
|
||||
captured.update(codex_plugin._build_responses_payload(
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
input_images=input_images,
|
||||
))
|
||||
return _b64_png()
|
||||
|
||||
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
|
||||
|
||||
result = provider.generate(
|
||||
"put this same person in a navy JK uniform",
|
||||
aspect_ratio="portrait",
|
||||
image_url=str(image_path),
|
||||
reference_image_urls=["https://example.com/ref.png"],
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["modality"] == "image"
|
||||
assert result["input_image_count"] == 2
|
||||
|
||||
content = captured["input"][0]["content"]
|
||||
assert content[0] == {
|
||||
"type": "input_text",
|
||||
"text": "put this same person in a navy JK uniform",
|
||||
}
|
||||
assert content[1]["type"] == "input_image"
|
||||
assert content[1]["image_url"].startswith("data:image/png;base64,")
|
||||
assert content[2] == {"type": "input_image", "image_url": "https://example.com/ref.png"}
|
||||
|
||||
def test_generate_clamps_reference_images_to_cap(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
captured = {}
|
||||
|
||||
def _collect(token, *, prompt, size, quality, input_images=None):
|
||||
captured["input_images"] = input_images
|
||||
return _b64_png()
|
||||
|
||||
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
|
||||
|
||||
refs = [f"https://example.com/ref-{idx}.png" for idx in range(20)]
|
||||
result = provider.generate("combine the references", reference_image_urls=refs)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["modality"] == "image"
|
||||
assert result["input_image_count"] == 16
|
||||
assert len(captured["input_images"]) == 16
|
||||
assert captured["input_images"][-1]["image_url"] == "https://example.com/ref-15.png"
|
||||
|
||||
def test_rejects_non_image_local_source(self, provider, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
text_path = tmp_path / "not-image.txt"
|
||||
text_path.write_text("hello")
|
||||
|
||||
result = provider.generate("edit this", image_url=str(text_path))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_input"
|
||||
assert "not a supported image" in result["error"]
|
||||
|
||||
def test_rejects_svg_local_source(self, provider, monkeypatch, tmp_path):
|
||||
# The shared magic-byte sniffer recognizes SVG, but gpt-image-2's
|
||||
# input_image accepts raster only — SVG must fail locally with a clear
|
||||
# error, not get embedded and rejected server-side with an opaque 400.
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
svg_path = tmp_path / "vector.svg"
|
||||
svg_path.write_text('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
||||
|
||||
result = provider.generate("edit this", image_url=str(svg_path))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_input"
|
||||
assert "not a supported image" in result["error"]
|
||||
|
||||
def test_partial_image_event_used_when_done_missing(self):
|
||||
"""If output_item.done is missing, partial_image_b64 is accepted."""
|
||||
payload = {
|
||||
"type": "response.image_generation_call.partial_image",
|
||||
"partial_image_b64": _b64_png(),
|
||||
}
|
||||
assert codex_plugin._extract_image_b64(payload) == _b64_png()
|
||||
|
||||
def test_sse_parser_handles_event_and_data_lines(self):
|
||||
class _Response:
|
||||
def iter_lines(self):
|
||||
return iter([
|
||||
"event: response.output_item.done",
|
||||
'data: {"item": {"type": "image_generation_call", "result": "abc"}}',
|
||||
"",
|
||||
])
|
||||
|
||||
events = list(codex_plugin._iter_sse_json(_Response()))
|
||||
assert events == [{
|
||||
"type": "response.output_item.done",
|
||||
"item": {"type": "image_generation_call", "result": "abc"},
|
||||
}]
|
||||
|
||||
def test_final_response_sweep_recovers_image(self):
|
||||
"""Completed response output is found by recursive payload scanning."""
|
||||
payload = {
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"output": [{
|
||||
"type": "image_generation_call",
|
||||
"status": "completed",
|
||||
"id": "ig_final",
|
||||
"result": _b64_png(),
|
||||
}],
|
||||
},
|
||||
}
|
||||
assert codex_plugin._extract_image_b64(payload) == _b64_png()
|
||||
|
||||
def test_empty_response_returns_error(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: None)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_stream_exception_returns_api_error(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("cloudflare 403")
|
||||
|
||||
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _boom)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "cloudflare 403" in result["error"]
|
||||
|
||||
|
||||
# ── Plugin entry point ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_register_calls_register_image_gen_provider(self):
|
||||
registered = []
|
||||
|
||||
class _Ctx:
|
||||
def register_image_gen_provider(self, prov):
|
||||
registered.append(prov)
|
||||
|
||||
codex_plugin.register(_Ctx())
|
||||
assert len(registered) == 1
|
||||
assert registered[0].name == "openai-codex"
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Tests for the bundled OpenAI image_gen plugin (gpt-image-2, three tiers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.image_gen.openai as openai_plugin
|
||||
|
||||
|
||||
# 1×1 transparent PNG — valid bytes for save_b64_image()
|
||||
_PNG_HEX = (
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
|
||||
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
|
||||
"ae426082"
|
||||
)
|
||||
|
||||
|
||||
def _b64_png() -> str:
|
||||
import base64
|
||||
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
|
||||
|
||||
|
||||
def _fake_response(*, b64=None, url=None, revised_prompt=None):
|
||||
item = SimpleNamespace(b64_json=b64, url=url, revised_prompt=revised_prompt)
|
||||
return SimpleNamespace(data=[item])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
return openai_plugin.OpenAIImageGenProvider()
|
||||
|
||||
|
||||
def _patched_openai(fake_client: MagicMock):
|
||||
fake_openai = MagicMock()
|
||||
fake_openai.OpenAI.return_value = fake_client
|
||||
return patch.dict("sys.modules", {"openai": fake_openai})
|
||||
|
||||
|
||||
# ── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetadata:
|
||||
def test_name(self, provider):
|
||||
assert provider.name == "openai"
|
||||
|
||||
def test_default_model(self, provider):
|
||||
assert provider.default_model() == "gpt-image-2-medium"
|
||||
|
||||
def test_list_models_three_tiers(self, provider):
|
||||
ids = [m["id"] for m in provider.list_models()]
|
||||
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
|
||||
|
||||
def test_catalog_entries_have_display_speed_strengths(self, provider):
|
||||
for entry in provider.list_models():
|
||||
assert entry["display"].startswith("GPT Image 2")
|
||||
assert entry["speed"]
|
||||
assert entry["strengths"]
|
||||
|
||||
|
||||
# ── Availability ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAvailability:
|
||||
def test_no_api_key_unavailable(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
assert openai_plugin.OpenAIImageGenProvider().is_available() is False
|
||||
|
||||
def test_api_key_set_available(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test")
|
||||
assert openai_plugin.OpenAIImageGenProvider().is_available() is True
|
||||
|
||||
|
||||
# ── Model resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModelResolution:
|
||||
def test_default_is_medium(self):
|
||||
model_id, meta = openai_plugin._resolve_model()
|
||||
assert model_id == "gpt-image-2-medium"
|
||||
assert meta["quality"] == "medium"
|
||||
|
||||
def test_env_var_override(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_IMAGE_MODEL", "gpt-image-2-high")
|
||||
model_id, meta = openai_plugin._resolve_model()
|
||||
assert model_id == "gpt-image-2-high"
|
||||
assert meta["quality"] == "high"
|
||||
|
||||
def test_env_var_unknown_falls_back(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_IMAGE_MODEL", "bogus-tier")
|
||||
model_id, _ = openai_plugin._resolve_model()
|
||||
assert model_id == openai_plugin.DEFAULT_MODEL
|
||||
|
||||
def test_config_openai_model(self, tmp_path):
|
||||
import yaml
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"image_gen": {"openai": {"model": "gpt-image-2-low"}}})
|
||||
)
|
||||
model_id, meta = openai_plugin._resolve_model()
|
||||
assert model_id == "gpt-image-2-low"
|
||||
assert meta["quality"] == "low"
|
||||
|
||||
def test_config_top_level_model(self, tmp_path):
|
||||
"""``image_gen.model: gpt-image-2-high`` also works (top-level)."""
|
||||
import yaml
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"image_gen": {"model": "gpt-image-2-high"}})
|
||||
)
|
||||
model_id, meta = openai_plugin._resolve_model()
|
||||
assert model_id == "gpt-image-2-high"
|
||||
assert meta["quality"] == "high"
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSourceImageLoading:
|
||||
def test_load_image_bytes_blocks_credential_store(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
openai_plugin._load_image_bytes(str(auth_json))
|
||||
|
||||
def test_load_image_bytes_never_opens_blocked_credential(self, tmp_path, monkeypatch):
|
||||
"""The guard must fire BEFORE the file is opened — a credential store
|
||||
must never be read into memory (#57698). Spy builtins.open and assert
|
||||
it is never called for the blocked path."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
import builtins
|
||||
|
||||
real_open = builtins.open
|
||||
opened: list = []
|
||||
|
||||
def _spy_open(file, *a, **k):
|
||||
opened.append(str(file))
|
||||
return real_open(file, *a, **k)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", _spy_open)
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
openai_plugin._load_image_bytes(str(auth_json))
|
||||
assert str(auth_json) not in opened, "blocked credential must never be opened"
|
||||
|
||||
def test_load_image_bytes_allows_legit_local_image(self, tmp_path, monkeypatch):
|
||||
"""Negative control: a legitimate local image path is NOT blocked and
|
||||
loads normally — proves the guard doesn't over-fire on everything."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
img = tmp_path / "pic.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||
|
||||
data, name = openai_plugin._load_image_bytes(str(img))
|
||||
assert data == b"\x89PNG\r\n\x1a\nfake-image-bytes"
|
||||
assert name == "pic.png"
|
||||
|
||||
def test_load_image_bytes_passthrough_data_uri_not_blocked(self, tmp_path, monkeypatch):
|
||||
"""Negative control: data: URIs are decoded, never routed through the
|
||||
local-path guard (the guard only applies to local file reads)."""
|
||||
import base64
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
b64 = base64.b64encode(b"xyz").decode("ascii")
|
||||
data, name = openai_plugin._load_image_bytes(f"data:image/png;base64,{b64}")
|
||||
assert data == b"xyz"
|
||||
assert name.endswith(".png")
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_empty_prompt_rejected(self, provider):
|
||||
result = provider.generate("", aspect_ratio="square")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_argument"
|
||||
|
||||
def test_missing_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
result = openai_plugin.OpenAIImageGenProvider().generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
def test_b64_saves_to_cache(self, provider, tmp_path):
|
||||
png_bytes = bytes.fromhex(_PNG_HEX)
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
|
||||
|
||||
with _patched_openai(fake_client):
|
||||
result = provider.generate("a cat", aspect_ratio="landscape")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["model"] == "gpt-image-2-medium"
|
||||
assert result["aspect_ratio"] == "landscape"
|
||||
assert result["provider"] == "openai"
|
||||
assert result["quality"] == "medium"
|
||||
|
||||
saved = Path(result["image"])
|
||||
assert saved.exists()
|
||||
assert saved.parent == tmp_path / "cache" / "images"
|
||||
assert saved.read_bytes() == png_bytes
|
||||
|
||||
call_kwargs = fake_client.images.generate.call_args.kwargs
|
||||
# All tiers hit the single underlying API model.
|
||||
assert call_kwargs["model"] == "gpt-image-2"
|
||||
assert call_kwargs["quality"] == "medium"
|
||||
assert call_kwargs["size"] == "1536x1024"
|
||||
# gpt-image-2 rejects response_format — we must NOT send it.
|
||||
assert "response_format" not in call_kwargs
|
||||
|
||||
@pytest.mark.parametrize("tier,expected_quality", [
|
||||
("gpt-image-2-low", "low"),
|
||||
("gpt-image-2-medium", "medium"),
|
||||
("gpt-image-2-high", "high"),
|
||||
])
|
||||
def test_tier_maps_to_quality(self, provider, monkeypatch, tier, expected_quality):
|
||||
monkeypatch.setenv("OPENAI_IMAGE_MODEL", tier)
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
|
||||
|
||||
with _patched_openai(fake_client):
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["model"] == tier
|
||||
assert result["quality"] == expected_quality
|
||||
assert fake_client.images.generate.call_args.kwargs["quality"] == expected_quality
|
||||
# Always the same underlying API model regardless of tier.
|
||||
assert fake_client.images.generate.call_args.kwargs["model"] == "gpt-image-2"
|
||||
|
||||
@pytest.mark.parametrize("aspect,expected_size", [
|
||||
("landscape", "1536x1024"),
|
||||
("square", "1024x1024"),
|
||||
("portrait", "1024x1536"),
|
||||
])
|
||||
def test_aspect_ratio_mapping(self, provider, aspect, expected_size):
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
|
||||
|
||||
with _patched_openai(fake_client):
|
||||
provider.generate("a cat", aspect_ratio=aspect)
|
||||
|
||||
assert fake_client.images.generate.call_args.kwargs["size"] == expected_size
|
||||
|
||||
def test_revised_prompt_passed_through(self, provider):
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(
|
||||
b64=_b64_png(), revised_prompt="A photo of a cat",
|
||||
)
|
||||
|
||||
with _patched_openai(fake_client):
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["revised_prompt"] == "A photo of a cat"
|
||||
|
||||
def test_api_error_returns_error_response(self, provider):
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.side_effect = RuntimeError("boom")
|
||||
|
||||
with _patched_openai(fake_client):
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "boom" in result["error"]
|
||||
|
||||
def test_empty_response_data(self, provider):
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = SimpleNamespace(data=[])
|
||||
|
||||
with _patched_openai(fake_client):
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_url_response_is_cached_locally(self, provider):
|
||||
"""OpenAI URL response (if API ever returns one) is cached locally.
|
||||
|
||||
Pre-fix this asserted the bare URL passed through; symmetric to the
|
||||
xAI #26942 fix. Even though gpt-image-2 returns b64 today, every
|
||||
``image_gen`` provider must guarantee the gateway gets a stable
|
||||
file path so ephemeral signed URLs can't expire mid-flight.
|
||||
"""
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(
|
||||
b64=None, url="https://example.com/img.png",
|
||||
)
|
||||
|
||||
with _patched_openai(fake_client), patch(
|
||||
"plugins.image_gen.openai.save_url_image",
|
||||
return_value=Path("/tmp/openai_gpt-image-2_20260524_000000_deadbeef.png"),
|
||||
) as mock_save_url:
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"].startswith("/")
|
||||
assert "example.com" not in result["image"]
|
||||
mock_save_url.assert_called_once()
|
||||
|
||||
def test_url_response_falls_back_to_bare_url_when_download_fails(self, provider):
|
||||
"""Cache failure must not turn into a tool error — symmetric with xAI."""
|
||||
import requests as req_lib
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(
|
||||
b64=None, url="https://example.com/img.png",
|
||||
)
|
||||
|
||||
with _patched_openai(fake_client), patch(
|
||||
"plugins.image_gen.openai.save_url_image",
|
||||
side_effect=req_lib.HTTPError("404 from CDN"),
|
||||
):
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "https://example.com/img.png"
|
||||
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the OpenRouter-compatible image gen provider (OpenRouter + Nous)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_RUNTIME = "hermes_cli.runtime_provider.resolve_runtime_provider"
|
||||
_PNG_DATA_URI = "data:image/png;base64,dGVzdC1pbWFnZS1kYXRh" # "test-image-data"
|
||||
|
||||
|
||||
def _runtime_ok(**over):
|
||||
base = {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "sk-or-test",
|
||||
"source": "env",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _mock_chat_response(images):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"images": [
|
||||
{"type": "image_url", "image_url": {"url": u}} for u in images
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _openrouter():
|
||||
from plugins.image_gen.openrouter import OpenRouterCompatImageProvider
|
||||
|
||||
return OpenRouterCompatImageProvider(
|
||||
provider_name="openrouter",
|
||||
display_name="OpenRouter",
|
||||
runtime_name="openrouter",
|
||||
config_key="openrouter",
|
||||
model_env_var="OPENROUTER_IMAGE_MODEL",
|
||||
setup_schema={"name": "OpenRouter (image)", "badge": "paid", "env_vars": []},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderClass:
|
||||
def test_names(self):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
names = {p.name for p in _build_providers()}
|
||||
assert names == {"openrouter", "nous"}
|
||||
|
||||
def test_display_names(self):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
by_name = {p.name: p for p in _build_providers()}
|
||||
assert by_name["openrouter"].display_name == "OpenRouter"
|
||||
assert by_name["nous"].display_name == "Nous Portal"
|
||||
|
||||
def test_capabilities_support_image_input(self):
|
||||
caps = _openrouter().capabilities()
|
||||
assert "image" in caps["modalities"]
|
||||
assert caps["max_reference_images"] >= 1
|
||||
|
||||
def test_is_available_with_key(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()):
|
||||
assert _openrouter().is_available() is True
|
||||
|
||||
def test_is_available_without_key(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
|
||||
assert _openrouter().is_available() is False
|
||||
|
||||
def test_is_available_on_resolution_error(self):
|
||||
with patch(_RUNTIME, side_effect=RuntimeError("boom")):
|
||||
assert _openrouter().is_available() is False
|
||||
|
||||
def test_default_model(self):
|
||||
from plugins.image_gen.openrouter import DEFAULT_MODEL
|
||||
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
|
||||
assert _openrouter().default_model() == DEFAULT_MODEL
|
||||
# Default must be an image-output model id (provider/model form).
|
||||
assert "/" in DEFAULT_MODEL and "image" in DEFAULT_MODEL
|
||||
|
||||
def test_default_chain_prefers_quality_then_fallback(self):
|
||||
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _DEFAULT_MODEL_CHAIN
|
||||
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
|
||||
chain = _openrouter()._resolve_model_chain()
|
||||
assert chain == list(_DEFAULT_MODEL_CHAIN)
|
||||
assert chain[0].startswith("openai/")
|
||||
assert chain[-1] == _FALLBACK_MODEL
|
||||
|
||||
def test_model_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "black-forest-labs/flux.2-pro")
|
||||
assert _openrouter()._resolve_model() == "black-forest-labs/flux.2-pro"
|
||||
assert _openrouter()._resolve_model_chain() == ["black-forest-labs/flux.2-pro"]
|
||||
|
||||
def test_model_config_override(self):
|
||||
cfg = {"openrouter": {"model": "google/gemini-3.1-flash-image-preview"}}
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
|
||||
assert _openrouter()._resolve_model() == "google/gemini-3.1-flash-image-preview"
|
||||
|
||||
def test_model_top_level_config_override(self):
|
||||
cfg = {"model": "openai/gpt-image-2"}
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
|
||||
assert _openrouter()._resolve_model_chain() == ["openai/gpt-image-2"]
|
||||
|
||||
def test_nous_honors_top_level_model(self):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
cfg = {"model": "openai/gpt-image-2"}
|
||||
nous = {p.name: p for p in _build_providers()}["nous"]
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
|
||||
assert nous._resolve_model_chain() == ["openai/gpt-image-2"]
|
||||
|
||||
def test_explicit_model_kwarg_wins_over_config(self):
|
||||
cfg = {"model": "openai/gpt-image-2"}
|
||||
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
|
||||
assert _openrouter()._resolve_model_chain("google/gemini-3-pro-image") == [
|
||||
"google/gemini-3-pro-image"
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_to_image_url_part_passthrough_url(self):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
assert _to_image_url_part("https://x/y.png") == "https://x/y.png"
|
||||
assert _to_image_url_part("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
|
||||
|
||||
def test_to_image_url_part_inlines_local_file(self, tmp_path):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
f = tmp_path / "base.png"
|
||||
f.write_bytes(b"\x89PNG\r\n")
|
||||
part = _to_image_url_part(str(f))
|
||||
assert part.startswith("data:image/png;base64,")
|
||||
decoded = base64.b64decode(part.split(",", 1)[1])
|
||||
assert decoded == b"\x89PNG\r\n"
|
||||
|
||||
def test_to_image_url_part_missing_file(self):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
assert _to_image_url_part("/no/such/file.png") is None
|
||||
|
||||
def test_to_image_url_part_blocks_credential_store(self, tmp_path, monkeypatch):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_to_image_url_part(str(auth_json))
|
||||
|
||||
def test_to_image_url_part_never_reads_blocked_credential(self, tmp_path, monkeypatch):
|
||||
"""The guard must fire BEFORE path.read_bytes() — the credential store
|
||||
must never be inlined into a provider request (#57698)."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
real_read_bytes = _P.read_bytes
|
||||
read: list = []
|
||||
|
||||
def _spy_read_bytes(self, *a, **k):
|
||||
read.append(str(self))
|
||||
return real_read_bytes(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(_P, "read_bytes", _spy_read_bytes)
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_to_image_url_part(str(auth_json))
|
||||
assert str(auth_json) not in read, "blocked credential must never be read"
|
||||
|
||||
def test_extract_images(self):
|
||||
from plugins.image_gen.openrouter import _extract_images
|
||||
|
||||
payload = {
|
||||
"choices": [
|
||||
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,AA"}}]}}
|
||||
]
|
||||
}
|
||||
assert _extract_images(payload) == ["data:image/png;base64,AA"]
|
||||
|
||||
def test_extract_images_empty(self):
|
||||
from plugins.image_gen.openrouter import _extract_images
|
||||
|
||||
assert _extract_images({"choices": [{"message": {"content": "no image"}}]}) == []
|
||||
|
||||
def test_access_error_hint_for_gated_openai_model(self):
|
||||
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _access_error_hint
|
||||
|
||||
hint = _access_error_hint(
|
||||
"OpenRouter", "openai/gpt-5.4-image-2", "OPENROUTER_IMAGE_MODEL", 404, "No endpoints found"
|
||||
)
|
||||
assert hint is not None
|
||||
assert "openai/gpt-5.4-image-2" in hint
|
||||
assert "OPENROUTER_IMAGE_MODEL" in hint
|
||||
assert _FALLBACK_MODEL in hint
|
||||
# Stays a single line under the humanizer's 200-char truncation.
|
||||
assert "\n" not in hint and len(hint) <= 200
|
||||
|
||||
def test_access_error_hint_ignores_non_openai_models(self):
|
||||
from plugins.image_gen.openrouter import _access_error_hint
|
||||
|
||||
assert _access_error_hint("OpenRouter", "google/gemini-3-pro-image", "X", 404, "boom") is None
|
||||
|
||||
def test_access_error_hint_ignores_unrelated_errors(self):
|
||||
from plugins.image_gen.openrouter import _access_error_hint
|
||||
|
||||
# A 200-class transient with an openai model but no access signal → no hint.
|
||||
assert _access_error_hint("OpenRouter", "openai/gpt-5.4-image-2", "X", 500, "server error") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_missing_credentials(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "missing_api_key"
|
||||
|
||||
def test_success_data_uri(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])), \
|
||||
patch(
|
||||
"plugins.image_gen.openrouter.save_b64_image",
|
||||
return_value=Path("/tmp/openrouter_gen.png"),
|
||||
) as mock_save:
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "/tmp/openrouter_gen.png"
|
||||
assert result["provider"] == "openrouter"
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_success_http_url(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response(["https://cdn/x.png"])), \
|
||||
patch(
|
||||
"plugins.image_gen.openrouter.save_url_image",
|
||||
return_value=Path("/tmp/openrouter_gen_url.png"),
|
||||
) as mock_save_url:
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "/tmp/openrouter_gen_url.png"
|
||||
mock_save_url.assert_called_once()
|
||||
|
||||
def test_empty_response(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([])):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_payload_shape_and_references(self, tmp_path):
|
||||
"""Wire payload must carry image modalities, aspect_ratio, and the
|
||||
reference image inlined as a data URI (this is what makes pet rows
|
||||
stay on-model)."""
|
||||
ref = tmp_path / "base.png"
|
||||
ref.write_bytes(b"\x89PNG\r\n")
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
_openrouter().generate(
|
||||
prompt="a pet", aspect_ratio="square", reference_images=[str(ref)]
|
||||
)
|
||||
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["modalities"] == ["image", "text"]
|
||||
assert payload["image_config"]["aspect_ratio"] == "1:1"
|
||||
content = payload["messages"][0]["content"]
|
||||
assert content[0] == {"type": "text", "text": "a pet"}
|
||||
image_parts = [c for c in content if c["type"] == "image_url"]
|
||||
assert len(image_parts) == 1
|
||||
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_auth_header(self):
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
_openrouter().generate(prompt="a pet")
|
||||
|
||||
headers = mock_post.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer sk-or-test"
|
||||
|
||||
def test_generate_uses_model_kwarg_from_dispatch(self):
|
||||
"""image_generate passes image_gen.model as a model kwarg — honor it."""
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
result = _openrouter().generate(prompt="a pet", model="openai/gpt-image-2")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["model"] == "openai/gpt-image-2"
|
||||
assert mock_post.call_args.kwargs["json"]["model"] == "openai/gpt-image-2"
|
||||
|
||||
def test_posts_to_resolved_base_url(self):
|
||||
"""Nous routes to its own base URL — proves the same code serves both."""
|
||||
nous_runtime = _runtime_ok(
|
||||
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
|
||||
)
|
||||
with patch(_RUNTIME, return_value=nous_runtime), \
|
||||
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
|
||||
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
|
||||
from plugins.image_gen.openrouter import _build_providers
|
||||
|
||||
nous = {p.name: p for p in _build_providers()}["nous"]
|
||||
result = nous.generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "nous"
|
||||
url = mock_post.call_args[0][0]
|
||||
assert url == "https://inference.nousresearch.com/v1/chat/completions"
|
||||
|
||||
def test_api_error(self):
|
||||
import requests as req_lib
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 401
|
||||
resp.text = "Unauthorized"
|
||||
resp.json.return_value = {"error": {"message": "Invalid API key"}}
|
||||
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=resp) as mock_post:
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
def test_timeout(self):
|
||||
import requests as req_lib
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", side_effect=req_lib.Timeout()):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "timeout"
|
||||
|
||||
def test_access_gated_model_surfaces_hint(self, monkeypatch):
|
||||
"""A 404 on an OpenAI image model yields the actionable access hint (not
|
||||
the misleading generic 'check your key' message)."""
|
||||
import requests as req_lib
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "openai/gpt-5.4-image-2")
|
||||
resp = MagicMock()
|
||||
resp.status_code = 404
|
||||
resp.text = "No endpoints found for openai/gpt-5.4-image-2"
|
||||
resp.json.return_value = {"error": {"message": "No endpoints found"}}
|
||||
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", return_value=resp) as mock_post:
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "model_access"
|
||||
assert "OpenAI image access" in result["error"]
|
||||
assert mock_post.call_count == 1 # explicit override: no auto-fallback chain
|
||||
|
||||
def test_access_gated_default_model_falls_back_to_gemini(self):
|
||||
import requests as req_lib
|
||||
|
||||
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
|
||||
|
||||
gated = MagicMock()
|
||||
gated.status_code = 404
|
||||
gated.text = f"No endpoints found for {DEFAULT_MODEL}"
|
||||
gated.json.return_value = {"error": {"message": "No endpoints found"}}
|
||||
gated.raise_for_status.side_effect = req_lib.HTTPError(response=gated)
|
||||
|
||||
with patch(_RUNTIME, return_value=_runtime_ok()), \
|
||||
patch("requests.post", side_effect=[gated, _mock_chat_response([_PNG_DATA_URI])]) as mock_post, \
|
||||
patch(
|
||||
"plugins.image_gen.openrouter.save_b64_image",
|
||||
return_value=Path("/tmp/openrouter_gen_fallback.png"),
|
||||
):
|
||||
result = _openrouter().generate(prompt="a pet")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["model"] == _FALLBACK_MODEL
|
||||
assert result["image"] == "/tmp/openrouter_gen_fallback.png"
|
||||
assert mock_post.call_count == 2
|
||||
first_model = mock_post.call_args_list[0].kwargs["json"]["model"]
|
||||
second_model = mock_post.call_args_list[1].kwargs["json"]["model"]
|
||||
assert first_model == DEFAULT_MODEL
|
||||
assert second_model == _FALLBACK_MODEL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration + pet integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_register_both(self):
|
||||
from plugins.image_gen.openrouter import register
|
||||
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
registered = [c.args[0].name for c in ctx.register_image_gen_provider.call_args_list]
|
||||
assert set(registered) == {"openrouter", "nous"}
|
||||
|
||||
def test_both_are_reference_capable_for_pets(self):
|
||||
from agent.pet.generate.imagegen import _REF_CAPABLE
|
||||
|
||||
assert "openrouter" in _REF_CAPABLE
|
||||
assert "nous" in _REF_CAPABLE
|
||||
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for xAI image generation provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_api_key(monkeypatch, tmp_path):
|
||||
"""Ensure XAI_API_KEY is set for all tests."""
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-key-12345")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
try:
|
||||
import hermes_cli.config as cfg_mod
|
||||
|
||||
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
|
||||
cfg_mod._invalidate_load_config_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider class tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestXAIImageGenProvider:
|
||||
def test_name(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
assert provider.name == "xai"
|
||||
|
||||
def test_display_name(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
assert provider.display_name == "xAI (Grok)"
|
||||
|
||||
def test_is_available_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("XAI_API_KEY", "sk-xxx")
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
assert provider.is_available() is True
|
||||
|
||||
def test_is_available_without_key(self, monkeypatch):
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
assert provider.is_available() is False
|
||||
|
||||
def test_list_models(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
models = provider.list_models()
|
||||
assert len(models) >= 1
|
||||
assert models[0]["id"] == "grok-imagine-image"
|
||||
|
||||
def test_default_model(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
assert provider.default_model() == "grok-imagine-image"
|
||||
|
||||
def test_get_setup_schema(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
schema = provider.get_setup_schema()
|
||||
assert schema["name"] == "xAI Grok Imagine (image)"
|
||||
assert schema["badge"] == "paid"
|
||||
# Auth resolution is delegated to the shared "xai_grok" post_setup
|
||||
# hook so the picker doesn't blindly prompt for XAI_API_KEY when the
|
||||
# user is already signed in via xAI Grok OAuth.
|
||||
assert schema["env_vars"] == []
|
||||
assert schema["post_setup"] == "xai_grok"
|
||||
|
||||
def test_capabilities_expose_total_source_image_limit(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
caps = XAIImageGenProvider().capabilities()
|
||||
assert caps["max_reference_images"] == 2
|
||||
assert caps["max_source_images"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_default_model(self):
|
||||
from plugins.image_gen.xai import _resolve_model
|
||||
|
||||
model_id, meta = _resolve_model()
|
||||
assert model_id == "grok-imagine-image"
|
||||
|
||||
def test_default_resolution(self):
|
||||
from plugins.image_gen.xai import _resolve_resolution
|
||||
|
||||
assert _resolve_resolution() == "1k"
|
||||
|
||||
def test_custom_model(self, monkeypatch):
|
||||
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
|
||||
from plugins.image_gen.xai import _resolve_model
|
||||
|
||||
model_id, _ = _resolve_model()
|
||||
assert model_id == "grok-imagine-image"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_missing_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="test")
|
||||
assert result["success"] is False
|
||||
assert "XAI_API_KEY" in result["error"]
|
||||
|
||||
def test_successful_generation(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"b64_json": "dGVzdC1pbWFnZS1kYXRh"}], # base64 "test-image-data"
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
|
||||
with patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "/tmp/test.png"
|
||||
assert result["provider"] == "xai"
|
||||
assert result["model"] == "grok-imagine-image"
|
||||
|
||||
def test_successful_url_response(self):
|
||||
"""xAI URL response is cached locally — #26942 contract.
|
||||
|
||||
Pre-fix this asserted ``result["image"] == "<the bare URL>"``, which
|
||||
was exactly the bug: xAI's ``imgen.x.ai/xai-tmp-*`` URLs expire fast
|
||||
and the gateway 404'd by ``send_photo`` time. Post-fix the URL
|
||||
bytes are downloaded at tool-completion and the result carries an
|
||||
absolute filesystem path the gateway can upload from.
|
||||
"""
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg"}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch(
|
||||
"plugins.image_gen.xai.save_url_image",
|
||||
return_value=Path("/tmp/xai_grok-imagine-image_20260524_000000_deadbeef.jpg"),
|
||||
) as mock_save_url:
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"].startswith("/"), (
|
||||
f"URL response must be cached to an absolute path, got {result['image']!r}"
|
||||
)
|
||||
assert "imgen.x.ai" not in result["image"], (
|
||||
"ephemeral xAI URL must not leak into result.image — caller will 404"
|
||||
)
|
||||
# The downloader should have been called exactly once with the URL
|
||||
# and an xai-prefixed cache filename.
|
||||
mock_save_url.assert_called_once()
|
||||
call_args, call_kwargs = mock_save_url.call_args
|
||||
assert call_args[0] == "https://imgen.x.ai/xai-tmp-imgen-test.jpeg"
|
||||
assert call_kwargs.get("prefix", "").startswith("xai_")
|
||||
|
||||
def test_url_response_falls_back_to_bare_url_when_download_fails(self):
|
||||
"""If caching the URL fails (network blip, 404 in-flight), the
|
||||
provider must NOT hard-error — fall through to returning the bare
|
||||
URL so the agent surface at least sees *something*. The gateway's
|
||||
existing URL-send fallback then has a chance to succeed; if it
|
||||
too 404s, the user gets the original (now legible) error rather
|
||||
than an opaque "image generation failed" tool result.
|
||||
"""
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch(
|
||||
"plugins.image_gen.xai.save_url_image",
|
||||
side_effect=req_lib.HTTPError("404 from CDN"),
|
||||
):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True, (
|
||||
"Cache failure must not turn into a tool error — gateway gets a chance to retry"
|
||||
)
|
||||
assert result["image"] == "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"
|
||||
|
||||
def test_api_error(self):
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
mock_resp.text = "Unauthorized"
|
||||
mock_resp.json.return_value = {"error": {"message": "Invalid API key"}}
|
||||
mock_resp.raise_for_status.side_effect = req_lib.HTTPError(response=mock_resp)
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
|
||||
def test_api_error_preserves_real_response_status(self):
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
response = req_lib.Response()
|
||||
response.status_code = 401
|
||||
response._content = json.dumps({"error": {"message": "Invalid API key"}}).encode()
|
||||
response.headers["Content-Type"] = "application/json"
|
||||
|
||||
response.raise_for_status = MagicMock(
|
||||
side_effect=req_lib.HTTPError(response=response)
|
||||
)
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=response):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "xAI image generation failed (401): Invalid API key" in result["error"]
|
||||
|
||||
def test_timeout(self):
|
||||
import requests as req_lib
|
||||
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", side_effect=req_lib.Timeout()):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "timeout"
|
||||
|
||||
def test_empty_response(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": []}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="test")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_auth_header(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"url": "https://xai.image/test.png"}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
|
||||
provider = XAIImageGenProvider()
|
||||
provider.generate(prompt="test")
|
||||
|
||||
call_args = mock_post.call_args
|
||||
headers = call_args.kwargs.get("headers") or call_args[1].get("headers")
|
||||
assert "Bearer test-key-12345" in headers["Authorization"]
|
||||
assert "Hermes-Agent" in headers["User-Agent"]
|
||||
|
||||
def test_payload_resolution_is_literal_1k_or_2k(self):
|
||||
"""Regression: xAI API rejects numeric resolutions ("1024"/"2048") with 422.
|
||||
|
||||
The endpoint expects the literal strings "1k" or "2k". Ensure the wire
|
||||
payload carries that literal — not a numeric mapping. See PR #18678.
|
||||
"""
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/test.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
|
||||
provider = XAIImageGenProvider()
|
||||
provider.generate(prompt="test")
|
||||
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["resolution"] in {"1k", "2k"}, (
|
||||
f"resolution must be the literal '1k' or '2k', got {payload['resolution']!r}"
|
||||
)
|
||||
|
||||
def test_image_edit_rejects_bare_file_id_input(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="make the robot red",
|
||||
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_url"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_image_edit_accepts_public_https_url(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
public_url = "https://files-cdn.x.ai/token/file_abc.png"
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="make the robot red",
|
||||
image_url=public_url,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["image"] == {"url": public_url, "type": "image_url"}
|
||||
|
||||
def test_multi_image_edit_rejects_bare_file_id_inputs(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="combine these robots into one product shot",
|
||||
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
reference_image_urls=[
|
||||
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
|
||||
"file_aa11bb22-cc33-44dd-88ee-ff0011223344",
|
||||
],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_url"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_multi_image_edit_rejects_more_than_three_sources(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="combine too many references",
|
||||
image_url="file_1",
|
||||
reference_image_urls=["file_2", "file_3", "file_4"],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "too_many_references"
|
||||
|
||||
def test_storage_options_are_sent_by_default(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
provider.generate(prompt="test")
|
||||
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["storage_options"]["public_url"] is True
|
||||
assert "expires_after" not in payload["storage_options"]
|
||||
assert payload["storage_options"]["filename"].endswith(".png")
|
||||
|
||||
def test_public_url_file_output_wins_over_temporary_url(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{
|
||||
"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg",
|
||||
"file_output": {
|
||||
"file_id": "file-123",
|
||||
"filename": "stored.png",
|
||||
"public_url": "https://xai-files.example/stored.png",
|
||||
"public_url_expires_at": 1234567890,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch("plugins.image_gen.xai.save_url_image") as mock_save_url:
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "https://xai-files.example/stored.png"
|
||||
assert result["public_url"] == "https://xai-files.example/stored.png"
|
||||
assert "file_id" not in result
|
||||
mock_save_url.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_register(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider, register
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
register(mock_ctx)
|
||||
mock_ctx.register_image_gen_provider.assert_called_once()
|
||||
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
|
||||
assert isinstance(provider, XAIImageGenProvider)
|
||||
assert provider.name == "xai"
|
||||
|
||||
|
||||
def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
|
||||
"""A ~-prefixed local image path must load (expanduser), not raise io_error.
|
||||
|
||||
Pre-flight validation uses ``Path(source).expanduser()`` so a ``~/...`` path
|
||||
passes; ``_xai_image_field`` must expand it too or the load fails spuriously.
|
||||
"""
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
img = tmp_path / "pic.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
field = _xai_image_field("~/pic.png")
|
||||
assert field["type"] == "image_url"
|
||||
assert field["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
class TestXAIImageFieldReadGuard:
|
||||
"""#57698: local image inputs must not read Hermes credential stores."""
|
||||
|
||||
def test_xai_image_field_blocks_credential_store(self, tmp_path, monkeypatch):
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_xai_image_field(str(auth_json))
|
||||
|
||||
def test_xai_image_field_never_opens_blocked_credential(self, tmp_path, monkeypatch):
|
||||
"""Guard fires before open() — credential store never read into memory."""
|
||||
import builtins
|
||||
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
real_open = builtins.open
|
||||
opened: list = []
|
||||
|
||||
def _spy_open(file, *a, **k):
|
||||
opened.append(str(file))
|
||||
return real_open(file, *a, **k)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", _spy_open)
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_xai_image_field(str(auth_json))
|
||||
assert str(auth_json) not in opened, "blocked credential must never be opened"
|
||||
|
||||
def test_xai_image_field_passthrough_url_not_blocked(self, monkeypatch):
|
||||
"""Negative control: remote URLs and data: URIs pass through unguarded."""
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
assert _xai_image_field("https://example.com/pic.png")["url"] == "https://example.com/pic.png"
|
||||
assert _xai_image_field("data:image/png;base64,eHl6")["url"].startswith("data:image/png")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the ByteRover memory provider config gates."""
|
||||
|
||||
from plugins.memory.byterover import ByteRoverMemoryProvider
|
||||
|
||||
|
||||
def test_auto_extract_false_skips_sync_turn(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": False})
|
||||
provider.initialize("session-1")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
|
||||
|
||||
provider.sync_turn("please remember this detail", "acknowledged")
|
||||
|
||||
assert calls == []
|
||||
assert provider._sync_thread is None
|
||||
|
||||
|
||||
def test_auto_extract_false_skips_memory_write(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": "false"})
|
||||
provider.initialize("session-1")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
|
||||
|
||||
provider.on_memory_write("add", "user", "User prefers concise responses")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_auto_extract_false_skips_pre_compress(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": "off"})
|
||||
provider.initialize("session-1")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
|
||||
|
||||
result = provider.on_pre_compress([
|
||||
{"role": "user", "content": "remember this"},
|
||||
{"role": "assistant", "content": "stored"},
|
||||
])
|
||||
|
||||
assert result == ""
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_auto_extract_false_keeps_explicit_curate_tool(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": False})
|
||||
provider.initialize("session-1")
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
calls.append(args)
|
||||
return {"success": True, "output": "ok"}
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", fake_run)
|
||||
|
||||
result = provider.handle_tool_call("brv_curate", {"content": "Important project fact"})
|
||||
|
||||
assert "Memory curated successfully" in result
|
||||
assert calls == [["curate", "--", "Important project fact"]]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""Tests for FactRetriever FTS5 query sanitization.
|
||||
|
||||
These tests cover the fix where raw natural-language queries passed to
|
||||
FTS5 MATCH were AND-joined by default, dropping recall to zero on any
|
||||
multi-word prose query. The sanitizer drops stopwords and OR-joins the
|
||||
remaining content tokens as phrase literals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("numpy") # retrieval module imports numpy indirectly
|
||||
|
||||
from plugins.memory.holographic.retrieval import FactRetriever
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_fts_query — unit tests (no DB required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,expected_tokens",
|
||||
[
|
||||
# stopwords dropped
|
||||
("what happened with the deployment rollback", {"happened", "deployment", "rollback"}),
|
||||
# single content word passes through
|
||||
("compaction", {"compaction"}),
|
||||
# all stopwords → falls back to raw
|
||||
("the and of", None), # None = sentinel for fallback-to-raw
|
||||
# empty string → empty output
|
||||
("", ""),
|
||||
# FTS5 operator characters stripped
|
||||
("context: length-probe", {"context", "lengthprobe"}),
|
||||
# trailing punctuation stripped by tokenizer
|
||||
("hello, world!", {"hello", "world"}),
|
||||
],
|
||||
)
|
||||
def test_sanitize_fts_query_extracts_content_tokens(query, expected_tokens):
|
||||
result = FactRetriever._sanitize_fts_query(query)
|
||||
|
||||
if expected_tokens == "":
|
||||
assert result == ""
|
||||
return
|
||||
|
||||
if expected_tokens is None:
|
||||
# Pathological case: all stopwords — should fall back to raw query
|
||||
assert result == query
|
||||
return
|
||||
|
||||
# OR-joined phrase literals: `"tok1" OR "tok2" OR ...`
|
||||
# Extract the tokens between quotes, order-independent.
|
||||
import re
|
||||
matches = re.findall(r'"([^"]+)"', result)
|
||||
assert set(matches) == expected_tokens, f"got {result!r}"
|
||||
|
||||
|
||||
def test_sanitize_fts_query_never_crashes_on_fts5_specials():
|
||||
"""Queries with FTS5 operator characters must not produce malformed SQL."""
|
||||
problematic = [
|
||||
'test " query',
|
||||
"test * query",
|
||||
"test (a OR b) query",
|
||||
"test^2 query",
|
||||
"test:colon query",
|
||||
"test-hyphen query",
|
||||
"a" * 1000, # long query
|
||||
]
|
||||
for q in problematic:
|
||||
result = FactRetriever._sanitize_fts_query(q)
|
||||
# We just need it to return a string without raising
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test — actually run _fts_candidates against an in-memory DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def retriever_with_facts(tmp_path):
|
||||
"""MemoryStore seeded with a few facts for retrieval tests."""
|
||||
db_path = tmp_path / "test_facts.db"
|
||||
store = MemoryStore(str(db_path))
|
||||
store.add_fact(
|
||||
content="The Thursday deployment rollback failed because of stale migration state.",
|
||||
category="project",
|
||||
)
|
||||
store.add_fact(
|
||||
content="Compaction settings tuned to 0.85 threshold.",
|
||||
category="tool",
|
||||
)
|
||||
store.add_fact(
|
||||
content="Venice.ai advertises availableContextTokens inside model_spec.",
|
||||
category="tool",
|
||||
)
|
||||
retriever = FactRetriever(store=store)
|
||||
yield retriever
|
||||
store.close()
|
||||
|
||||
|
||||
def test_prefetch_recovers_prose_query(retriever_with_facts):
|
||||
"""A natural-language query should now match the relevant fact.
|
||||
|
||||
Before the sanitizer fix, 'what happened with the deployment rollback'
|
||||
returned zero hits because FTS5 required every token to co-occur.
|
||||
"""
|
||||
results = retriever_with_facts.search(
|
||||
"what happened with the deployment rollback"
|
||||
)
|
||||
assert len(results) >= 1
|
||||
# The top hit should be the deployment rollback fact
|
||||
assert "deployment rollback" in results[0]["content"].lower()
|
||||
|
||||
|
||||
def test_prefetch_single_keyword_still_works(retriever_with_facts):
|
||||
"""Single-term queries (pre-fix working case) remain working."""
|
||||
results = retriever_with_facts.search("compaction")
|
||||
assert len(results) >= 1
|
||||
assert "Compaction" in results[0]["content"] or "compaction" in results[0]["content"].lower()
|
||||
|
||||
|
||||
def test_prefetch_stopword_only_query_empty(retriever_with_facts):
|
||||
"""Pure stopword queries return zero results but don't crash."""
|
||||
# Pass to _sanitize_fts_query directly first so we know what happens
|
||||
assert FactRetriever._sanitize_fts_query("the and of") == "the and of"
|
||||
# search() handles the likely-zero-hit case gracefully
|
||||
results = retriever_with_facts.search("the and of")
|
||||
# Either zero results or it errored-gracefully to [] — both are fine
|
||||
assert isinstance(results, list)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Regression test for #44037 — holographic provider leaked its SQLite
|
||||
connection to GC on shutdown instead of closing it.
|
||||
|
||||
The corruption-mechanism framing in #44037 (TLS bytes written into the DB via
|
||||
an fd-recycle race) was not reproducible from the code: dropping a sqlite
|
||||
connection flushes valid pages through SQLite's own VFS, never TLS framing, and
|
||||
the provider is at most a *releaser* of DB fds, not the TLS-flushing owner.
|
||||
|
||||
But the underlying resource-hygiene bug is real and is what this test pins:
|
||||
``HolographicMemoryProvider.shutdown()`` must call ``MemoryStore.close()`` so
|
||||
the ``check_same_thread=False`` connection's fd is released deterministically
|
||||
on shutdown, rather than at a non-deterministic GC time on an arbitrary thread.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
|
||||
def _make_provider(tmp_path):
|
||||
db_path = str(tmp_path / "memory_store.db")
|
||||
provider = HolographicMemoryProvider(config={"db_path": db_path, "hrr_dim": 64})
|
||||
provider.initialize(session_id="test-session")
|
||||
return provider
|
||||
|
||||
|
||||
def test_shutdown_closes_store_connection(tmp_path):
|
||||
provider = _make_provider(tmp_path)
|
||||
store = provider._store
|
||||
assert store is not None
|
||||
conn = store._conn
|
||||
|
||||
# Connection is live before shutdown.
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
|
||||
provider.shutdown()
|
||||
|
||||
# References are dropped...
|
||||
assert provider._store is None
|
||||
assert provider._retriever is None
|
||||
|
||||
# ...AND the underlying connection was actually closed (not left to GC).
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
|
||||
def test_shutdown_is_idempotent_and_safe_without_store(tmp_path):
|
||||
provider = _make_provider(tmp_path)
|
||||
provider.shutdown()
|
||||
# Second shutdown (store already None) must not raise.
|
||||
provider.shutdown()
|
||||
|
||||
# A provider that was never initialized must also shut down cleanly.
|
||||
bare = HolographicMemoryProvider(config={"db_path": str(tmp_path / "x.db")})
|
||||
bare.shutdown()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Tests for the holographic MemoryStore shared-connection registry.
|
||||
|
||||
MemoryStore instances pointing at the same database file must share one
|
||||
process-wide SQLite connection and one re-entrant lock. Multiple providers
|
||||
coexist in a single process (the main agent plus every delegate_task
|
||||
subagent); when each instance owned a private connection they raced as
|
||||
independent WAL writers and intermittently failed with "database is locked".
|
||||
|
||||
Covers: connection sharing/refcounting, close() semantics, cross-instance
|
||||
visibility, concurrent multi-instance writers, and write-lock release after
|
||||
a failed write.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_shared_registry():
|
||||
"""Each test starts and ends with an empty shared-connection registry."""
|
||||
# Drop any leakage from earlier tests in the same process.
|
||||
for entry in list(MemoryStore._shared.values()):
|
||||
try:
|
||||
entry["conn"].close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
MemoryStore._shared.clear()
|
||||
yield
|
||||
leaked = list(MemoryStore._shared)
|
||||
for entry in list(MemoryStore._shared.values()):
|
||||
try:
|
||||
entry["conn"].close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
MemoryStore._shared.clear()
|
||||
assert not leaked, f"test leaked shared connections: {leaked}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
return tmp_path / "memory_store.db"
|
||||
|
||||
|
||||
class TestSharedConnection:
|
||||
def test_same_path_shares_one_connection(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
try:
|
||||
assert a._conn is b._conn
|
||||
assert a._lock is b._lock
|
||||
assert len(MemoryStore._shared) == 1
|
||||
assert MemoryStore._shared[str(a.db_path)]["refs"] == 2
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_different_paths_get_distinct_connections(self, tmp_path):
|
||||
a = MemoryStore(tmp_path / "one.db")
|
||||
b = MemoryStore(tmp_path / "two.db")
|
||||
try:
|
||||
assert a._conn is not b._conn
|
||||
assert len(MemoryStore._shared) == 2
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_symlinked_path_shares_connection(self, tmp_path):
|
||||
"""A symlink to the same DB file must hit the same registry entry —
|
||||
otherwise two connections to one file silently reintroduce the
|
||||
multi-writer contention the registry exists to prevent."""
|
||||
real_dir = tmp_path / "real"
|
||||
real_dir.mkdir()
|
||||
link_dir = tmp_path / "link"
|
||||
link_dir.symlink_to(real_dir)
|
||||
|
||||
a = MemoryStore(real_dir / "memory_store.db")
|
||||
b = MemoryStore(link_dir / "memory_store.db")
|
||||
try:
|
||||
assert a._conn is b._conn
|
||||
assert len(MemoryStore._shared) == 1
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_writes_visible_across_instances(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
try:
|
||||
fact_id = a.add_fact("Hermes likes shared connections", category="test")
|
||||
facts = b.list_facts(category="test")
|
||||
assert [f["fact_id"] for f in facts] == [fact_id]
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_schema_initialised_once_per_connection(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path) # must not re-run schema init / WAL probe
|
||||
try:
|
||||
assert MemoryStore._shared[str(a.db_path)]["ready"] is True
|
||||
b.add_fact("schema still works")
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
|
||||
class TestCloseSemantics:
|
||||
def test_closing_one_instance_keeps_sibling_alive(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
a.close()
|
||||
try:
|
||||
# The shared connection must survive the sibling's close().
|
||||
fact_id = b.add_fact("survivor write")
|
||||
assert fact_id > 0
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def test_last_close_releases_connection(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
conn = a._conn
|
||||
a.close()
|
||||
b.close()
|
||||
assert MemoryStore._shared == {}
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
def test_close_is_idempotent(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
a.close()
|
||||
a.close() # double close must not steal b's reference
|
||||
try:
|
||||
b.add_fact("still alive after double close")
|
||||
assert MemoryStore._shared[str(b.db_path)]["refs"] == 1
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def test_context_manager_releases_reference(self, db_path):
|
||||
with MemoryStore(db_path) as store:
|
||||
store.add_fact("context managed")
|
||||
assert MemoryStore._shared == {}
|
||||
|
||||
def test_reopen_after_full_close(self, db_path):
|
||||
with MemoryStore(db_path) as store:
|
||||
store.add_fact("first lifetime")
|
||||
with MemoryStore(db_path) as store:
|
||||
facts = store.list_facts()
|
||||
assert [f["content"] for f in facts] == ["first lifetime"]
|
||||
|
||||
|
||||
class TestConcurrency:
|
||||
def test_concurrent_multi_instance_writers(self, db_path):
|
||||
"""Many instances writing from many threads must never hit
|
||||
'database is locked' — the failure mode of per-instance connections."""
|
||||
n_threads, n_facts = 8, 15
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def writer(idx: int) -> None:
|
||||
store = MemoryStore(db_path)
|
||||
try:
|
||||
for i in range(n_facts):
|
||||
store.add_fact(f"fact thread={idx} seq={i}", category="load")
|
||||
except BaseException as exc: # noqa: BLE001 - recorded for assert
|
||||
errors.append(exc)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"concurrent writers failed: {errors[:3]}"
|
||||
with MemoryStore(db_path) as store:
|
||||
facts = store.list_facts(category="load", limit=500)
|
||||
assert len(facts) == n_threads * n_facts
|
||||
assert MemoryStore._shared == {}
|
||||
|
||||
def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch):
|
||||
"""A write that raises mid-method must not leave an open transaction
|
||||
holding the SQLite write lock (autocommit isolation_level=None)."""
|
||||
broken = MemoryStore(db_path)
|
||||
sibling = MemoryStore(db_path)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
MemoryStore,
|
||||
"_rebuild_bank",
|
||||
lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
broken.add_fact("write that fails after the INSERT")
|
||||
monkeypatch.undo()
|
||||
|
||||
# No dangling transaction: the connection reports autocommit state
|
||||
# and the sibling can write immediately.
|
||||
assert broken._conn.in_transaction is False
|
||||
sibling.add_fact("sibling write right after the failure")
|
||||
finally:
|
||||
broken.close()
|
||||
sibling.close()
|
||||
|
||||
|
||||
class TestProviderShutdown:
|
||||
"""The provider's shutdown() must release its shared connection, not just
|
||||
drop the reference. Leaving finalization to GC keeps the connection (and
|
||||
its write lock) alive on a long-running gateway, which is exactly the
|
||||
"database is locked" contention the shared-connection registry removes."""
|
||||
|
||||
def test_shutdown_releases_shared_connection(self, db_path):
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
provider = HolographicMemoryProvider(config={"db_path": str(db_path)})
|
||||
provider.initialize("session-shutdown")
|
||||
assert MemoryStore._shared[str(db_path)]["refs"] == 1
|
||||
|
||||
provider.shutdown()
|
||||
|
||||
assert provider._store is None
|
||||
assert MemoryStore._shared == {}
|
||||
|
||||
def test_shutdown_keeps_sibling_provider_alive(self, db_path):
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
a = HolographicMemoryProvider(config={"db_path": str(db_path)})
|
||||
b = HolographicMemoryProvider(config={"db_path": str(db_path)})
|
||||
a.initialize("session-a")
|
||||
b.initialize("session-b")
|
||||
assert MemoryStore._shared[str(db_path)]["refs"] == 2
|
||||
|
||||
a.shutdown()
|
||||
# Sibling still holds a live, writable connection.
|
||||
assert MemoryStore._shared[str(db_path)]["refs"] == 1
|
||||
assert b._store is not None
|
||||
b._store.add_fact("write after sibling shutdown")
|
||||
b.shutdown()
|
||||
assert MemoryStore._shared == {}
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0._backend import (
|
||||
Mem0Backend,
|
||||
PlatformBackend,
|
||||
OSSBackend,
|
||||
SelfHostedBackend,
|
||||
)
|
||||
|
||||
|
||||
class FakePlatformClient:
|
||||
"""Fake MemoryClient for PlatformBackend tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def search(self, query, **kwargs):
|
||||
self.calls.append(("search", query, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.9}]}
|
||||
|
||||
def get_all(self, **kwargs):
|
||||
self.calls.append(("get_all", kwargs))
|
||||
return {"count": 1, "next": None, "results": [{"id": "m1", "memory": "fact1"}]}
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
self.calls.append(("add", messages, kwargs))
|
||||
return {"status": "PENDING", "event_id": "evt-1"}
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.calls.append(("update", kwargs))
|
||||
return {"id": kwargs["memory_id"], "text": kwargs["text"]}
|
||||
|
||||
def delete(self, **kwargs):
|
||||
self.calls.append(("delete", kwargs))
|
||||
|
||||
|
||||
class TestPlatformBackend:
|
||||
|
||||
def _make(self):
|
||||
client = FakePlatformClient()
|
||||
backend = PlatformBackend.__new__(PlatformBackend)
|
||||
backend._client = client
|
||||
return backend, client
|
||||
|
||||
def test_search_forwards_params(self):
|
||||
backend, client = self._make()
|
||||
result = backend.search("test query", filters={"user_id": "u1"}, top_k=5)
|
||||
assert client.calls[0][0] == "search"
|
||||
assert client.calls[0][1] == "test query"
|
||||
assert client.calls[0][2]["filters"] == {"user_id": "u1"}
|
||||
assert client.calls[0][2]["top_k"] == 5
|
||||
|
||||
def test_search_forwards_rerank(self):
|
||||
backend, client = self._make()
|
||||
backend.search("q", filters={}, rerank=False)
|
||||
assert client.calls[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_default_false(self):
|
||||
backend, client = self._make()
|
||||
backend.search("q", filters={})
|
||||
assert client.calls[0][2]["rerank"] is False
|
||||
|
||||
def test_search_returns_list(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.search("q", filters={})
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["id"] == "m1"
|
||||
|
||||
def test_add_forwards_kwargs(self):
|
||||
backend, client = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
result = backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
|
||||
call = client.calls[0]
|
||||
assert call[2]["user_id"] == "u1"
|
||||
assert call[2]["infer"] is False
|
||||
# metadata kwarg should be omitted entirely when not provided so we
|
||||
# don't surprise older mem0 client versions with an unknown kwarg.
|
||||
assert "metadata" not in call[2]
|
||||
|
||||
def test_add_forwards_metadata_when_present(self):
|
||||
backend, client = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
backend.add(
|
||||
msgs,
|
||||
user_id="u1",
|
||||
agent_id="hermes",
|
||||
infer=False,
|
||||
metadata={"channel": "telegram"},
|
||||
)
|
||||
assert client.calls[0][2]["metadata"] == {"channel": "telegram"}
|
||||
|
||||
def test_add_omits_empty_metadata(self):
|
||||
backend, client = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={})
|
||||
assert "metadata" not in client.calls[0][2]
|
||||
|
||||
def test_update_forwards(self):
|
||||
backend, client = self._make()
|
||||
backend.update("m1", "new text")
|
||||
assert client.calls[0][1] == {"memory_id": "m1", "text": "new text"}
|
||||
|
||||
def test_delete_forwards(self):
|
||||
backend, client = self._make()
|
||||
backend.delete("m1")
|
||||
assert client.calls[0][1] == {"memory_id": "m1"}
|
||||
|
||||
|
||||
class FakeOSSMemory:
|
||||
"""Fake mem0.Memory for OSSBackend tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def search(self, query, **kwargs):
|
||||
self.calls.append(("search", query, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.8}]}
|
||||
|
||||
def get_all(self, **kwargs):
|
||||
self.calls.append(("get_all", kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1"}]}
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
self.calls.append(("add", messages, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1", "event": "ADD"}]}
|
||||
|
||||
def update(self, memory_id, **kwargs):
|
||||
self.calls.append(("update", memory_id, kwargs))
|
||||
return {"message": "Memory updated successfully!"}
|
||||
|
||||
def delete(self, memory_id):
|
||||
self.calls.append(("delete", memory_id))
|
||||
return {"message": "Memory deleted successfully!"}
|
||||
|
||||
|
||||
class TestOSSBackend:
|
||||
|
||||
def _make(self):
|
||||
memory = FakeOSSMemory()
|
||||
backend = OSSBackend.__new__(OSSBackend)
|
||||
backend._memory = memory
|
||||
return backend, memory
|
||||
|
||||
def test_search_returns_list(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.search("test", filters={"user_id": "u1"})
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["id"] == "m1"
|
||||
|
||||
def test_search_passes_filters(self):
|
||||
backend, memory = self._make()
|
||||
backend.search("q", filters={"user_id": "u1"}, top_k=3)
|
||||
assert memory.calls[0][2]["filters"] == {"user_id": "u1"}
|
||||
assert memory.calls[0][2]["top_k"] == 3
|
||||
|
||||
def test_search_ignores_rerank(self):
|
||||
"""OSS backend accepts rerank param but does not forward it to Memory."""
|
||||
backend, memory = self._make()
|
||||
backend.search("q", filters={}, rerank=True)
|
||||
assert "rerank" not in memory.calls[0][2]
|
||||
|
||||
def test_add_forwards_kwargs(self):
|
||||
backend, memory = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
|
||||
assert memory.calls[0][2]["user_id"] == "u1"
|
||||
assert memory.calls[0][2]["infer"] is False
|
||||
|
||||
def test_update_maps_text_to_data(self):
|
||||
"""OSS Memory.update uses `data=` param, not `text=`."""
|
||||
backend, memory = self._make()
|
||||
backend.update("m1", "new text")
|
||||
assert memory.calls[0][0] == "update"
|
||||
assert memory.calls[0][1] == "m1"
|
||||
assert memory.calls[0][2] == {"data": "new text"}
|
||||
|
||||
def test_delete_positional_arg(self):
|
||||
backend, memory = self._make()
|
||||
backend.delete("m1")
|
||||
assert memory.calls[0] == ("delete", "m1")
|
||||
|
||||
def test_update_normalizes_response(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.update("m1", "text")
|
||||
assert result == {"result": "Memory updated.", "memory_id": "m1"}
|
||||
|
||||
def test_delete_normalizes_response(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.delete("m1")
|
||||
assert result == {"result": "Memory deleted.", "memory_id": "m1"}
|
||||
|
||||
|
||||
httpx = pytest.importorskip("httpx")
|
||||
|
||||
|
||||
class _StubServer:
|
||||
"""Records requests and serves the real self-hosted server's response shapes."""
|
||||
|
||||
def __init__(self, rows=10):
|
||||
self.requests = []
|
||||
self._rows = [{"id": f"m{i}", "memory": f"f{i}"} for i in range(rows)]
|
||||
|
||||
def handler(self, request):
|
||||
self.requests.append(request)
|
||||
path, method = request.url.path, request.method
|
||||
if path == "/search" and method == "POST":
|
||||
return httpx.Response(200, json={"results": [{"id": "m1", "memory": "tea", "score": 0.9}]})
|
||||
if path == "/memories" and method == "GET":
|
||||
top_k = int(request.url.params.get("top_k", len(self._rows)))
|
||||
return httpx.Response(200, json={"results": self._rows[:top_k]})
|
||||
if path == "/memories" and method == "POST":
|
||||
return httpx.Response(200, json={"results": [{"id": "new", "memory": "stored", "event": "ADD"}]})
|
||||
if path.startswith("/memories/") and method in ("PUT", "DELETE"):
|
||||
if path.endswith("/missing"): # server 404s unknown ids
|
||||
return httpx.Response(404, json={"detail": "Memory not found"})
|
||||
verb = "updated" if method == "PUT" else "Memory deleted successfully"
|
||||
return httpx.Response(200, json={"message": verb})
|
||||
return httpx.Response(404, json={"detail": "not found"})
|
||||
|
||||
|
||||
def _backend(server, api_key="adminkey", host="http://sh:8888"):
|
||||
"""Build a SelfHostedBackend routed through the stub transport.
|
||||
|
||||
Uses the real __init__ (via the injectable ``transport`` kwarg) so the
|
||||
constructor's header/base_url setup is exercised by every test here.
|
||||
"""
|
||||
return SelfHostedBackend(
|
||||
api_key, host, transport=httpx.MockTransport(server.handler)
|
||||
)
|
||||
|
||||
|
||||
class TestSelfHostedBackend:
|
||||
# --- constructor / auth setup (the crux of the bug) -------------------
|
||||
|
||||
def test_init_uses_x_api_key_not_token_auth(self):
|
||||
b = SelfHostedBackend("adminkey", "http://sh:8888")
|
||||
assert b._client.headers["x-api-key"] == "adminkey"
|
||||
assert "authorization" not in b._client.headers # NOT the cloud 'Token' scheme
|
||||
|
||||
def test_init_strips_trailing_slash(self):
|
||||
b = SelfHostedBackend("k", "http://sh:8888/")
|
||||
assert str(b._client.base_url) == "http://sh:8888"
|
||||
|
||||
def test_init_omits_api_key_header_when_blank(self):
|
||||
b = SelfHostedBackend("", "http://sh:8888") # AUTH_DISABLED server
|
||||
assert "x-api-key" not in b._client.headers
|
||||
|
||||
# --- search ----------------------------------------------------------
|
||||
|
||||
def test_search_posts_to_search_with_filters_in_body(self):
|
||||
s = _StubServer()
|
||||
results = _backend(s).search("drink", filters={"user_id": "u1"}, top_k=5)
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("POST", "/search")
|
||||
import json
|
||||
body = json.loads(req.content)
|
||||
assert body == {"query": "drink", "top_k": 5, "filters": {"user_id": "u1"}}
|
||||
assert results == [{"id": "m1", "memory": "tea", "score": 0.9}]
|
||||
|
||||
def test_search_sends_x_api_key_header(self):
|
||||
s = _StubServer()
|
||||
_backend(s).search("q", filters={"user_id": "u1"})
|
||||
req = s.requests[-1]
|
||||
assert req.headers["x-api-key"] == "adminkey"
|
||||
assert "authorization" not in req.headers
|
||||
|
||||
# --- add / update / delete ------------------------------------------
|
||||
|
||||
def test_add_posts_messages_and_identity(self):
|
||||
s = _StubServer()
|
||||
msgs = [{"role": "user", "content": "likes tea"}]
|
||||
result = _backend(s).add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={"channel": "cli"})
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("POST", "/memories")
|
||||
import json
|
||||
body = json.loads(req.content)
|
||||
assert body == {"messages": msgs, "user_id": "u1", "agent_id": "hermes",
|
||||
"infer": False, "metadata": {"channel": "cli"}}
|
||||
assert result["results"][0]["id"] == "new"
|
||||
|
||||
def test_update_puts_text_to_memory_id(self):
|
||||
s = _StubServer()
|
||||
result = _backend(s).update("abc", "new text")
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("PUT", "/memories/abc")
|
||||
import json
|
||||
assert json.loads(req.content) == {"text": "new text"}
|
||||
assert result == {"result": "Memory updated.", "memory_id": "abc"}
|
||||
|
||||
def test_delete_calls_delete_endpoint(self):
|
||||
s = _StubServer()
|
||||
result = _backend(s).delete("abc")
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("DELETE", "/memories/abc")
|
||||
assert result == {"result": "Memory deleted.", "memory_id": "abc"}
|
||||
|
||||
# --- error propagation (feeds the plugin's circuit breaker) ----------
|
||||
|
||||
def test_http_error_raises(self):
|
||||
s = _StubServer()
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_backend(s).delete("missing") # 404 -> raise_for_status; 'not found' won't trip breaker
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for OSS provider definitions and validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0._oss_providers import (
|
||||
LLM_PROVIDERS,
|
||||
EMBEDDER_PROVIDERS,
|
||||
VECTOR_PROVIDERS,
|
||||
KNOWN_DIMS,
|
||||
validate_oss_config,
|
||||
)
|
||||
|
||||
|
||||
class TestProviderDefinitions:
|
||||
|
||||
def test_llm_providers_have_required_keys(self):
|
||||
for pid, p in LLM_PROVIDERS.items():
|
||||
assert "label" in p
|
||||
assert "needs_key" in p
|
||||
assert "default_model" in p
|
||||
|
||||
def test_embedder_providers_have_required_keys(self):
|
||||
for pid, p in EMBEDDER_PROVIDERS.items():
|
||||
assert "label" in p
|
||||
assert "needs_key" in p
|
||||
assert "default_model" in p
|
||||
assert "dims" in p
|
||||
|
||||
def test_embedder_provider_ids(self):
|
||||
assert set(EMBEDDER_PROVIDERS.keys()) == {"openai", "ollama"}
|
||||
|
||||
def test_vector_providers_have_required_keys(self):
|
||||
for pid, p in VECTOR_PROVIDERS.items():
|
||||
assert "label" in p
|
||||
assert "default_config" in p
|
||||
|
||||
def test_vector_provider_ids(self):
|
||||
assert set(VECTOR_PROVIDERS.keys()) == {"qdrant", "pgvector"}
|
||||
|
||||
def test_known_dims_covers_defaults(self):
|
||||
for pid, p in EMBEDDER_PROVIDERS.items():
|
||||
assert p["default_model"] in KNOWN_DIMS
|
||||
|
||||
|
||||
class TestValidation:
|
||||
|
||||
def test_valid_openai_config(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
|
||||
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
|
||||
"vector_store": {"provider": "qdrant", "config": {"path": "/tmp/test"}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert errors == []
|
||||
|
||||
def test_unknown_llm_provider(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "gemini", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("llm" in e.lower() for e in errors)
|
||||
|
||||
def test_unknown_embedder_provider(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "cohere", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("embedder" in e.lower() for e in errors)
|
||||
|
||||
def test_unknown_vector_provider(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "redis", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("vector" in e.lower() for e in errors)
|
||||
|
||||
def test_missing_llm_section(self):
|
||||
cfg = {
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("llm" in e.lower() for e in errors)
|
||||
|
||||
def test_pgvector_needs_user(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "pgvector", "config": {"host": "localhost"}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("user" in e.lower() for e in errors)
|
||||
|
||||
def test_pgvector_with_user_valid(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "pgvector", "config": {"host": "localhost", "user": "pg"}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert errors == []
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Tests for Mem0 setup wizard — flag parsing, config building, validation."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from plugins.memory.mem0._setup import (
|
||||
parse_flags,
|
||||
build_oss_config,
|
||||
_write_env,
|
||||
post_setup,
|
||||
_check_qdrant_path,
|
||||
_check_ollama,
|
||||
_check_pgvector,
|
||||
)
|
||||
|
||||
|
||||
def _inject_fake_hermes_cli(monkeypatch):
|
||||
"""Inject fake hermes_cli modules so yaml/curses aren't required."""
|
||||
fake_config_mod = types.ModuleType("hermes_cli.config")
|
||||
fake_config_mod.save_config = lambda c: None
|
||||
|
||||
fake_setup_mod = types.ModuleType("hermes_cli.memory_setup")
|
||||
fake_setup_mod._curses_select = lambda *a, **kw: 0
|
||||
fake_setup_mod._prompt = lambda label, default=None, secret=False: default or ""
|
||||
|
||||
fake_hermes_cli = types.ModuleType("hermes_cli")
|
||||
fake_hermes_cli.config = fake_config_mod
|
||||
fake_hermes_cli.memory_setup = fake_setup_mod
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.config", fake_config_mod)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.memory_setup", fake_setup_mod)
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._curses_select", lambda *a, **kw: 0)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._prompt", lambda label, default=None, secret=False: default or "")
|
||||
return fake_config_mod
|
||||
|
||||
|
||||
class TestParseFlags:
|
||||
|
||||
def test_mode_platform(self):
|
||||
flags = parse_flags(["--mode", "platform", "--api-key", "sk-test"])
|
||||
assert flags["mode"] == "platform"
|
||||
assert flags["api_key"] == "sk-test"
|
||||
|
||||
def test_mode_oss_defaults(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
assert flags["mode"] == "oss"
|
||||
assert flags["oss_llm"] == "openai"
|
||||
assert flags["oss_embedder"] == "openai"
|
||||
assert flags["oss_vector"] == "qdrant"
|
||||
|
||||
def test_mode_oss_all_flags(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss",
|
||||
"--oss-llm", "ollama",
|
||||
"--oss-llm-model", "llama3:latest",
|
||||
"--oss-embedder", "ollama",
|
||||
"--oss-embedder-model", "nomic-embed-text",
|
||||
"--oss-vector", "pgvector",
|
||||
"--oss-vector-host", "db.local",
|
||||
"--oss-vector-port", "5433",
|
||||
"--oss-vector-user", "pguser",
|
||||
"--oss-vector-password", "secret",
|
||||
"--oss-vector-dbname", "memdb",
|
||||
"--user-id", "my-user",
|
||||
])
|
||||
assert flags["oss_llm"] == "ollama"
|
||||
assert flags["oss_llm_model"] == "llama3:latest"
|
||||
assert flags["oss_vector"] == "pgvector"
|
||||
assert flags["oss_vector_user"] == "pguser"
|
||||
assert flags["user_id"] == "my-user"
|
||||
|
||||
def test_no_flags_returns_empty_mode(self):
|
||||
flags = parse_flags([])
|
||||
assert flags["mode"] == ""
|
||||
|
||||
def test_oss_vector_path_flag(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-vector-path", "/data/qdrant"])
|
||||
assert flags["oss_vector_path"] == "/data/qdrant"
|
||||
|
||||
|
||||
class TestBuildOSSConfig:
|
||||
|
||||
def test_openai_defaults(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
oss, env_writes = build_oss_config(flags)
|
||||
assert oss["llm"]["provider"] == "openai"
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
|
||||
assert oss["embedder"]["provider"] == "openai"
|
||||
assert oss["embedder"]["config"]["model"] == "text-embedding-3-small"
|
||||
assert oss["vector_store"]["provider"] == "qdrant"
|
||||
assert env_writes["OPENAI_API_KEY"] == "sk-oai"
|
||||
|
||||
def test_ollama_no_key_needed(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm", "ollama", "--oss-embedder", "ollama"])
|
||||
oss, env_writes = build_oss_config(flags)
|
||||
assert oss["llm"]["provider"] == "ollama"
|
||||
assert "model" in oss["llm"]["config"]
|
||||
assert env_writes == {}
|
||||
|
||||
def test_embedder_reuses_llm_key(self):
|
||||
"""When LLM and embedder share same provider, key written once."""
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
_, env_writes = build_oss_config(flags)
|
||||
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
|
||||
|
||||
def test_different_embedder_needs_separate_key(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss",
|
||||
"--oss-llm", "ollama",
|
||||
"--oss-embedder", "openai", "--oss-embedder-key", "sk-oai",
|
||||
])
|
||||
_, env_writes = build_oss_config(flags)
|
||||
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
|
||||
|
||||
def test_pgvector_config(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-vector", "pgvector",
|
||||
"--oss-vector-host", "db.local", "--oss-vector-port", "5433",
|
||||
"--oss-vector-user", "pg", "--oss-vector-dbname", "memdb",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
vs = oss["vector_store"]
|
||||
assert vs["provider"] == "pgvector"
|
||||
assert vs["config"]["host"] == "db.local"
|
||||
assert vs["config"]["port"] == 5433
|
||||
assert vs["config"]["user"] == "pg"
|
||||
|
||||
def test_known_dims_auto_set(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
oss, _ = build_oss_config(flags)
|
||||
dims = oss["embedder"]["config"].get("embedding_dims")
|
||||
assert dims == 1536
|
||||
|
||||
def test_custom_qdrant_path(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-vector-path", "/data/qdrant",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
assert oss["vector_store"]["config"]["path"] == "/data/qdrant"
|
||||
|
||||
|
||||
class TestWriteEnv:
|
||||
|
||||
def test_write_new_vars(self, tmp_path):
|
||||
env_path = tmp_path / ".env"
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
|
||||
content = env_path.read_text()
|
||||
assert "OPENAI_API_KEY=sk-test" in content
|
||||
|
||||
def test_update_existing_var(self, tmp_path):
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("OPENAI_API_KEY=old\nOTHER=keep\n")
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "new"})
|
||||
content = env_path.read_text()
|
||||
assert "OPENAI_API_KEY=new" in content
|
||||
assert "OTHER=keep" in content
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
class TestPostSetup:
|
||||
|
||||
def test_platform_flag_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert config["memory"]["provider"] == "mem0"
|
||||
env_content = (tmp_path / ".env").read_text()
|
||||
assert "MEM0_API_KEY=sk-test" in env_content
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["mode"] == "platform"
|
||||
|
||||
def test_platform_setup_clears_stale_host(self, tmp_path, monkeypatch):
|
||||
# A user who previously ran self-hosted has host in mem0.json. Switching
|
||||
# to platform must drop host — otherwise routing (host > platform) keeps
|
||||
# sending them to the self-hosted server despite --mode platform.
|
||||
(tmp_path / "mem0.json").write_text(
|
||||
json.dumps({"mode": "platform", "host": "http://old-selfhosted:8888"})
|
||||
)
|
||||
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["mode"] == "platform"
|
||||
assert not mem0_json.get("host") # cleared to falsy so routing → platform
|
||||
|
||||
def test_oss_flag_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._install_provider_deps", lambda l, e, v: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert config["memory"]["provider"] == "mem0"
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["mode"] == "oss"
|
||||
assert mem0_json["oss"]["llm"]["provider"] == "openai"
|
||||
|
||||
def test_selfhosted_flag_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "selfhosted",
|
||||
"--host", "http://localhost:8888/", "--api-key", "admin-key",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert config["memory"]["provider"] == "mem0"
|
||||
env_content = (tmp_path / ".env").read_text()
|
||||
assert "MEM0_API_KEY=admin-key" in env_content
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["host"] == "http://localhost:8888" # trailing slash stripped
|
||||
assert mem0_json["user_id"] == "hermes-user"
|
||||
|
||||
def test_selfhosted_no_api_key_auth_disabled(self, tmp_path, monkeypatch):
|
||||
# AUTH_DISABLED servers need no key — setup must not write one.
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "self-hosted", "--host", "http://mem0.lan:8888",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert not (tmp_path / ".env").exists()
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["host"] == "http://mem0.lan:8888"
|
||||
|
||||
def test_selfhosted_dry_run_no_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "selfhosted",
|
||||
"--host", "http://localhost:8888", "--api-key", "k", "--dry-run",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert not (tmp_path / ".env").exists()
|
||||
assert not (tmp_path / "mem0.json").exists()
|
||||
assert "provider" not in config["memory"]
|
||||
|
||||
|
||||
class TestDryRun:
|
||||
|
||||
def test_dry_run_flag_parsed(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run"])
|
||||
assert flags["dry_run"] is True
|
||||
|
||||
def test_dry_run_not_set_by_default(self):
|
||||
flags = parse_flags(["--mode", "oss"])
|
||||
assert flags["dry_run"] is False
|
||||
|
||||
def test_dry_run_platform_no_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test", "--dry-run"])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert not (tmp_path / ".env").exists()
|
||||
assert not (tmp_path / "mem0.json").exists()
|
||||
assert "provider" not in config["memory"]
|
||||
|
||||
def test_dry_run_oss_no_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._install_provider_deps", lambda l, e, v: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert not (tmp_path / ".env").exists()
|
||||
assert not (tmp_path / "mem0.json").exists()
|
||||
assert "provider" not in config["memory"]
|
||||
|
||||
|
||||
class TestConnectivityChecks:
|
||||
|
||||
def test_qdrant_path_writable(self, tmp_path):
|
||||
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
|
||||
assert ok is True
|
||||
|
||||
def test_qdrant_path_not_writable(self, tmp_path, monkeypatch):
|
||||
def _raise_oserror(*a, **kw):
|
||||
raise OSError("Permission denied")
|
||||
monkeypatch.setattr(Path, "mkdir", _raise_oserror)
|
||||
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
|
||||
assert ok is False
|
||||
assert "Permission denied" in msg
|
||||
|
||||
def test_ollama_unreachable(self):
|
||||
ok, msg = _check_ollama("http://localhost:1")
|
||||
assert ok is False
|
||||
|
||||
def test_pgvector_unreachable(self):
|
||||
ok, msg = _check_pgvector("localhost", 1)
|
||||
assert ok is False
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Tests for Mem0 v3 API — new tool names, paginated responses, update/delete tools."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import pytest
|
||||
|
||||
import plugins.memory.mem0 as mem0_plugin
|
||||
from plugins.memory.mem0 import Mem0MemoryProvider
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
"""Fake Mem0Backend for provider-level tests."""
|
||||
|
||||
def __init__(self, search_results=None, all_results=None):
|
||||
self._search_results = search_results or []
|
||||
self._all_results = all_results or {"results": [], "count": 0}
|
||||
self.captured = []
|
||||
|
||||
def search(self, query, *, filters, top_k=10, rerank=True):
|
||||
self.captured.append(("search", query, {"filters": filters, "top_k": top_k, "rerank": rerank}))
|
||||
return self._search_results
|
||||
|
||||
def get_all(self, *, filters, page=1, page_size=100):
|
||||
self.captured.append(("get_all", {"filters": filters, "page": page, "page_size": page_size}))
|
||||
return self._all_results
|
||||
|
||||
def add(self, messages, *, user_id, agent_id, infer=False, metadata=None):
|
||||
self.captured.append((
|
||||
"add",
|
||||
messages,
|
||||
{"user_id": user_id, "agent_id": agent_id, "infer": infer, "metadata": metadata},
|
||||
))
|
||||
return {"status": "PENDING", "event_id": "evt-test-123"}
|
||||
|
||||
def update(self, memory_id, text):
|
||||
self.captured.append(("update", memory_id, text))
|
||||
return {"result": "Memory updated.", "memory_id": memory_id}
|
||||
|
||||
def delete(self, memory_id):
|
||||
self.captured.append(("delete", memory_id))
|
||||
return {"result": "Memory deleted.", "memory_id": memory_id}
|
||||
|
||||
|
||||
class TestMem0V3Tools:
|
||||
"""Test v3 tool names and response handling."""
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_search_returns_ids(self, monkeypatch):
|
||||
backend = FakeBackend(search_results=[{"id": "mem-1", "memory": "foo", "score": 0.9}])
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_search", {"query": "test"}))
|
||||
assert result["results"][0]["id"] == "mem-1"
|
||||
|
||||
def test_search_uses_filters(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "hello", "top_k": 3})
|
||||
assert backend.captured[0][2]["filters"] == {"user_id": "u123"}
|
||||
assert backend.captured[0][2]["top_k"] == 3
|
||||
|
||||
def test_search_rerank_default_false(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "test"})
|
||||
assert backend.captured[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_override_false(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "test", "rerank": False})
|
||||
assert backend.captured[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_config_default_used_when_arg_absent(self, monkeypatch):
|
||||
"""The persisted mem0.json ``rerank`` preference is the tool default;
|
||||
a per-call arg still wins (see the explicit-False override above)."""
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider._rerank_default = True # as initialize() sets from config
|
||||
provider.handle_tool_call("mem0_search", {"query": "test"})
|
||||
assert backend.captured[0][2]["rerank"] is True
|
||||
|
||||
def test_add_uses_content_param(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"}))
|
||||
assert len(backend.captured) == 1
|
||||
call = backend.captured[0]
|
||||
assert call[2]["infer"] is False
|
||||
assert call[2]["user_id"] == "u123"
|
||||
assert call[2]["agent_id"] == "hermes"
|
||||
assert "event_id" in result
|
||||
|
||||
def test_add_returns_event_id(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "test"}))
|
||||
assert result["event_id"] == "evt-test-123"
|
||||
|
||||
def test_add_missing_content(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_add", {}))
|
||||
assert "error" in result
|
||||
|
||||
def test_old_tool_names_return_unknown(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_profile", {}))
|
||||
assert "error" in result
|
||||
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestMem0UpdateDelete:
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_update_calls_sdk(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_update", {"memory_id": "mem-1", "text": "updated fact"}
|
||||
))
|
||||
assert backend.captured[0][1] == "mem-1"
|
||||
assert backend.captured[0][2] == "updated fact"
|
||||
assert result["result"] == "Memory updated."
|
||||
assert result["memory_id"] == "mem-1"
|
||||
|
||||
def test_update_missing_memory_id(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_update", {"text": "no id"}))
|
||||
assert "error" in result
|
||||
|
||||
def test_update_missing_text(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_update", {"memory_id": "mem-1"}))
|
||||
assert "error" in result
|
||||
|
||||
def test_delete_calls_sdk(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_delete", {"memory_id": "mem-1"}
|
||||
))
|
||||
assert backend.captured[0][1] == "mem-1"
|
||||
assert result["result"] == "Memory deleted."
|
||||
|
||||
def test_delete_missing_memory_id(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_delete", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestMem0ErrorHandling:
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_update_404_no_circuit_breaker(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
backend.update = lambda mid, text: (_ for _ in ()).throw(Exception("404 Not Found"))
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_update", {"memory_id": "bad-id", "text": "x"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_delete_404_no_circuit_breaker(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
backend.delete = lambda mid: (_ for _ in ()).throw(Exception("404 not found"))
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_delete", {"memory_id": "bad-id"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_update_validation_error_no_circuit_breaker(self, monkeypatch):
|
||||
"""ValidationError (bad UUID format) should not trip circuit breaker."""
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
backend = FakeBackend()
|
||||
backend.update = lambda mid, text: (_ for _ in ()).throw(
|
||||
ValidationError('{"error":"memory_id should be a valid UUID"}')
|
||||
)
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_update", {"memory_id": "not-a-uuid", "text": "x"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_delete_validation_error_no_circuit_breaker(self, monkeypatch):
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
backend = FakeBackend()
|
||||
backend.delete = lambda mid: (_ for _ in ()).throw(
|
||||
ValidationError('{"error":"memory_id should be a valid UUID"}')
|
||||
)
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_delete", {"memory_id": "not-a-uuid"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_update_5xx_trips_circuit_breaker(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
backend.update = lambda mid, text: (_ for _ in ()).throw(Exception("500 Internal Server Error"))
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_update", {"memory_id": "mem-1", "text": "x"})
|
||||
assert provider._consecutive_failures == 1
|
||||
|
||||
|
||||
class TestMem0V3Internal:
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_sync_turn_explicit_kwargs(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.sync_turn("user said", "assistant replied", session_id="s1")
|
||||
provider._sync_thread.join(timeout=2)
|
||||
assert len(backend.captured) == 1
|
||||
call = backend.captured[0]
|
||||
assert call[2]["user_id"] == "u123"
|
||||
assert call[2]["agent_id"] == "hermes"
|
||||
assert call[2]["infer"] is True
|
||||
|
||||
def test_old_tool_names_return_unknown(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_profile", {}))
|
||||
assert "error" in result
|
||||
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
|
||||
assert "error" in result
|
||||
result = json.loads(provider.handle_tool_call("mem0_list", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestMem0Prefetch:
|
||||
"""prefetch() must recall on the CURRENT question, synchronously.
|
||||
|
||||
The old implementation ignored its ``query`` and returned whatever a
|
||||
background ``queue_prefetch`` had warmed from the PREVIOUS turn — so the
|
||||
first turn injected nothing and later turns injected stale, off-topic
|
||||
memories. These lock the corrected behaviour.
|
||||
"""
|
||||
|
||||
def _make_provider(self, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_prefetch_searches_current_query(self):
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "user prefers dark mode"}])
|
||||
provider = self._make_provider(backend)
|
||||
result = provider.prefetch("what theme do I like?")
|
||||
kind, query, opts = backend.captured[0]
|
||||
assert kind == "search"
|
||||
assert query == "what theme do I like?"
|
||||
assert opts["filters"] == {"user_id": "u123"}
|
||||
assert opts["top_k"] == 10
|
||||
assert opts["rerank"] is False
|
||||
assert "## Mem0 Memory" in result
|
||||
assert "user prefers dark mode" in result
|
||||
|
||||
def test_prefetch_returns_memories_on_first_call(self):
|
||||
# No prior queue_prefetch / warm — the very first call must still recall.
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
provider = self._make_provider(backend)
|
||||
result = provider.prefetch("where do I live?")
|
||||
assert "lives in Berlin" in result
|
||||
|
||||
def test_on_turn_start_queues_current_query(self):
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
provider = self._make_provider(backend)
|
||||
provider.on_turn_start(1, "where do I live?")
|
||||
provider._prefetch_thread.join(timeout=1)
|
||||
result = provider.prefetch("where do I live?")
|
||||
assert "lives in Berlin" in result
|
||||
assert len([c for c in backend.captured if c[0] == "search"]) == 1
|
||||
|
||||
def test_slow_prefetch_returns_quickly(self, monkeypatch):
|
||||
class SlowBackend(FakeBackend):
|
||||
def search(self, query, *, filters, top_k=10, rerank=True):
|
||||
time.sleep(0.2)
|
||||
return super().search(query, filters=filters, top_k=top_k, rerank=rerank)
|
||||
|
||||
monkeypatch.setattr(mem0_plugin, "_PREFETCH_WAIT_SECS", 0.01)
|
||||
provider = self._make_provider(
|
||||
SlowBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
)
|
||||
started = time.monotonic()
|
||||
assert provider.prefetch("where do I live?") == ""
|
||||
assert time.monotonic() - started < 0.1
|
||||
provider._prefetch_thread.join(timeout=1)
|
||||
assert "lives in Berlin" in provider.prefetch("where do I live?")
|
||||
|
||||
def test_prefetch_empty_results_returns_empty(self):
|
||||
backend = FakeBackend(search_results=[])
|
||||
provider = self._make_provider(backend)
|
||||
assert provider.prefetch("anything") == ""
|
||||
|
||||
def test_prefetch_skips_when_breaker_open(self):
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
|
||||
provider = self._make_provider(backend)
|
||||
provider._consecutive_failures = 5
|
||||
provider._breaker_open_until = float("inf")
|
||||
assert provider.prefetch("q") == ""
|
||||
assert backend.captured == []
|
||||
|
||||
def test_queue_prefetch_fires_no_search(self):
|
||||
# prefetch is synchronous now, so the post-turn warm is redundant and
|
||||
# must not fire a wasted backend search.
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
|
||||
provider = self._make_provider(backend)
|
||||
provider.queue_prefetch("previous turn text")
|
||||
assert backend.captured == []
|
||||
|
||||
|
||||
class TestMem0V3Config:
|
||||
|
||||
def test_tool_schemas_four_tools(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
names = [s["name"] for s in schemas]
|
||||
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
|
||||
def test_system_prompt_new_tool_names(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
block = provider.system_prompt_block()
|
||||
assert "mem0_search" in block
|
||||
assert "mem0_add" in block
|
||||
assert "mem0_update" in block
|
||||
assert "mem0_delete" in block
|
||||
assert "mem0_list" not in block
|
||||
assert "mem0_profile" not in block
|
||||
assert "mem0_conclude" not in block
|
||||
|
||||
def test_system_prompt_shows_platform_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
provider._mode = "platform"
|
||||
block = provider.system_prompt_block()
|
||||
assert "platform" in block
|
||||
assert "Rerank" in block
|
||||
|
||||
def test_system_prompt_shows_oss_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
provider._mode = "oss"
|
||||
block = provider.system_prompt_block()
|
||||
assert "OSS" in block
|
||||
assert "Rerank" not in block
|
||||
|
||||
def test_search_schema_has_rerank(self):
|
||||
"""rerank property available in SEARCH_SCHEMA for platform mode."""
|
||||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
search = next(s for s in schemas if s["name"] == "mem0_search")
|
||||
assert "rerank" in search["parameters"]["properties"]
|
||||
assert search["parameters"]["properties"]["rerank"]["type"] == "boolean"
|
||||
|
||||
|
||||
class TestMem0ModeSwitch:
|
||||
|
||||
def test_default_mode_is_platform(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test")
|
||||
assert provider._mode == "platform"
|
||||
|
||||
def test_missing_mode_key_defaults_platform(self, monkeypatch, tmp_path):
|
||||
"""Backward compat: old mem0.json without mode key works."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_path = tmp_path / "mem0.json"
|
||||
config_path.write_text('{"user_id": "old-user"}')
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test")
|
||||
assert provider._mode == "platform"
|
||||
assert provider._user_id == "old-user"
|
||||
|
||||
def test_is_available_platform_needs_key(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
provider = Mem0MemoryProvider()
|
||||
assert provider.is_available() is False
|
||||
|
||||
def test_is_available_oss_needs_vector(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_path = tmp_path / "mem0.json"
|
||||
config_path.write_text('{"mode": "oss", "oss": {"vector_store": {"provider": "qdrant"}}}')
|
||||
provider = Mem0MemoryProvider()
|
||||
assert provider.is_available() is True
|
||||
|
||||
def test_is_available_oss_no_vector(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_path = tmp_path / "mem0.json"
|
||||
config_path.write_text('{"mode": "oss", "oss": {}}')
|
||||
provider = Mem0MemoryProvider()
|
||||
assert provider.is_available() is False
|
||||
|
||||
def test_tool_schemas_unchanged(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
names = [s["name"] for s in schemas]
|
||||
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
|
||||
def test_system_prompt_includes_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
provider._mode = "oss"
|
||||
block = provider.system_prompt_block()
|
||||
assert "mem0_search" in block
|
||||
assert "OSS" in block
|
||||
|
||||
|
||||
class TestMem0UserIdResolution:
|
||||
"""user_id resolution: configured override > gateway-native id > placeholder.
|
||||
|
||||
Same human across CLI / Telegram / Discord / Slack / etc. should map to
|
||||
the same memory store when MEM0_USER_ID is set, and only fall back to the
|
||||
gateway-native id when it isn't.
|
||||
"""
|
||||
|
||||
def _provider(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
provider = Mem0MemoryProvider()
|
||||
# Skip backend instantiation — we only care about identity resolution.
|
||||
provider._create_backend = lambda: None # type: ignore[method-assign]
|
||||
return provider
|
||||
|
||||
def test_env_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MEM0_USER_ID", "ryan@example.com")
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "ryan@example.com"
|
||||
|
||||
def test_file_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
(tmp_path / "mem0.json").write_text('{"user_id": "ryan@example.com"}')
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "ryan@example.com"
|
||||
|
||||
def test_unset_falls_back_to_gateway_native_id(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "123456789"
|
||||
|
||||
def test_unset_and_no_kwargs_falls_back_to_default(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test")
|
||||
assert provider._user_id == "hermes-user"
|
||||
|
||||
def test_legacy_placeholder_in_config_does_not_override_kwargs(self, monkeypatch, tmp_path):
|
||||
# Setup wizard historically wrote {"user_id": "hermes-user"} as the
|
||||
# suggested default. Treat that placeholder as unset so users on
|
||||
# gateways still get gateway-native ids — not silent collisions.
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
(tmp_path / "mem0.json").write_text('{"user_id": "hermes-user"}')
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "123456789"
|
||||
|
||||
|
||||
class TestMem0WriteMetadata:
|
||||
"""Writes carry metadata.channel so per-channel filtered views are possible
|
||||
without coupling identity to the channel.
|
||||
"""
|
||||
|
||||
def _make_provider(self, channel: str = "cli"):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._channel = channel
|
||||
provider._backend = FakeBackend()
|
||||
return provider
|
||||
|
||||
def test_add_tool_passes_channel_metadata(self):
|
||||
provider = self._make_provider("telegram")
|
||||
provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"})
|
||||
call = provider._backend.captured[-1]
|
||||
assert call[2]["metadata"] == {"channel": "telegram"}
|
||||
|
||||
def test_sync_turn_passes_channel_metadata(self):
|
||||
provider = self._make_provider("discord")
|
||||
provider.sync_turn("hi", "hello", session_id="s")
|
||||
# sync_turn fires a daemon thread; wait for it.
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
adds = [c for c in provider._backend.captured if c[0] == "add"]
|
||||
assert adds, "expected an add call from sync_turn"
|
||||
assert adds[-1][2]["metadata"] == {"channel": "discord"}
|
||||
|
||||
|
||||
class _SentinelBackend:
|
||||
def __init__(self, *args):
|
||||
self.args = args
|
||||
|
||||
|
||||
class TestCreateBackendRouting:
|
||||
"""_create_backend() must pick the backend matching the configured mode/host."""
|
||||
|
||||
def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""):
|
||||
# Neutralize lazy-install so the routing decision is all we exercise.
|
||||
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False)
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._mode = mode
|
||||
provider._api_key = api_key
|
||||
provider._host = host
|
||||
provider._config = {"oss": {"vector_store": {"provider": "qdrant"}}}
|
||||
return provider
|
||||
|
||||
def test_routes_to_selfhosted_when_host_set(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class SH(_SentinelBackend):
|
||||
def __init__(self, api_key, host):
|
||||
captured["args"] = (api_key, host)
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.SelfHostedBackend", SH)
|
||||
provider = self._provider(monkeypatch, host="http://sh:8888", api_key="adminkey")
|
||||
backend = provider._create_backend()
|
||||
assert isinstance(backend, SH)
|
||||
assert captured["args"] == ("adminkey", "http://sh:8888")
|
||||
|
||||
def test_routes_to_platform_when_no_host(self, monkeypatch):
|
||||
class PB(_SentinelBackend):
|
||||
def __init__(self, api_key):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.PlatformBackend", PB)
|
||||
provider = self._provider(monkeypatch, host="")
|
||||
assert isinstance(provider._create_backend(), PB)
|
||||
|
||||
def test_routes_to_oss_when_mode_oss(self, monkeypatch):
|
||||
class OB(_SentinelBackend):
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
|
||||
provider = self._provider(monkeypatch, mode="oss")
|
||||
assert isinstance(provider._create_backend(), OB)
|
||||
|
||||
def test_oss_mode_takes_precedence_over_host(self, monkeypatch):
|
||||
class OB(_SentinelBackend):
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
|
||||
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
|
||||
assert isinstance(provider._create_backend(), OB)
|
||||
|
||||
def test_prompt_label_matches_routing_when_oss_and_host_both_set(self, monkeypatch):
|
||||
# system_prompt_block must mirror _create_backend precedence: with both
|
||||
# mode=oss and host set, OSS wins the routing, so the prompt must label
|
||||
# OSS — not "self-hosted (HTTP API)". Guards the prompt-vs-routing lie.
|
||||
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
|
||||
provider._user_id = "test"
|
||||
block = provider.system_prompt_block()
|
||||
assert "OSS" in block
|
||||
assert "HTTP API" not in block
|
||||
|
||||
|
||||
class TestSelfHostedConfig:
|
||||
"""Config plumbing for self-hosted (MEM0_HOST env + is_available)."""
|
||||
|
||||
def test_load_config_reads_mem0_host_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
|
||||
assert mem0_plugin._load_config()["host"] == "http://localhost:8888"
|
||||
|
||||
def test_is_available_true_with_host_only(self, monkeypatch):
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MEM0_MODE", "platform")
|
||||
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
|
||||
assert Mem0MemoryProvider().is_available() is True
|
||||
|
||||
def test_is_available_false_without_key_or_host(self, monkeypatch):
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MEM0_HOST", raising=False)
|
||||
monkeypatch.setenv("MEM0_MODE", "platform")
|
||||
assert Mem0MemoryProvider().is_available() is False
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Regression tests: supermemory + mem0 memory providers must lazy-install
|
||||
their SDKs like honcho/hindsight.
|
||||
|
||||
Both providers ship a third-party SDK (``supermemory`` / ``mem0ai``) that is
|
||||
NOT a core dependency. Before this fix they imported the SDK directly with no
|
||||
``tools.lazy_deps.ensure()`` preflight and had no ``LAZY_DEPS`` allowlist
|
||||
entry. On the published Docker image the agent venv is sealed
|
||||
(``HERMES_DISABLE_LAZY_INSTALLS=1``) and lazy installs are redirected to a
|
||||
writable durable target (``HERMES_LAZY_INSTALL_TARGET``). honcho/hindsight
|
||||
route through ``ensure()`` and therefore install fine on a hosted instance;
|
||||
supermemory/mem0 never called it, so the SDK was never installed there and
|
||||
the provider silently reported itself unavailable.
|
||||
|
||||
These tests pin the contract:
|
||||
|
||||
1. Both features are in the ``LAZY_DEPS`` allowlist (without an entry,
|
||||
``ensure()`` raises ``FeatureUnavailable`` — the original silent-dark bug).
|
||||
2. Each provider's SDK-import chokepoint actually calls ``ensure(<feature>)``.
|
||||
3. supermemory's ``is_available()`` no longer gates on the SDK being
|
||||
importable (the chicken-and-egg trap that stopped the provider loading at
|
||||
all on a sealed venv, so ``initialize()``/``ensure()`` never ran).
|
||||
4. The real sealed-venv durable-target gate accepts the new features (the
|
||||
exact hosted-Fly condition the user hit).
|
||||
|
||||
The pip subprocess is never actually run — ``_venv_pip_install`` /
|
||||
``_is_satisfied`` are stubbed so we exercise the real ``ensure()`` control
|
||||
flow without touching PyPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.lazy_deps as ld
|
||||
|
||||
|
||||
MEMORY_FEATURES = ("memory.supermemory", "memory.mem0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Allowlist contract — the core regression.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlistEntries:
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_feature_is_allowlisted(self, feature):
|
||||
# Without an allowlist entry, ensure() raises FeatureUnavailable with
|
||||
# "not in LAZY_DEPS" — which is exactly why the SDK never installed on
|
||||
# a hosted instance before this fix.
|
||||
assert feature in ld.LAZY_DEPS, (
|
||||
f"{feature!r} missing from LAZY_DEPS — its SDK can never "
|
||||
f"lazy-install on a sealed Docker venv."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_feature_specs_pass_safety(self, feature):
|
||||
for spec in ld.LAZY_DEPS[feature]:
|
||||
assert ld._spec_is_safe(spec), f"{feature}: {spec!r} fails safety"
|
||||
|
||||
def test_supermemory_spec_package(self):
|
||||
specs = ld.LAZY_DEPS["memory.supermemory"]
|
||||
assert any(ld._pkg_name_from_spec(s) == "supermemory" for s in specs)
|
||||
|
||||
def test_mem0_spec_package(self):
|
||||
# mem0's pip package is ``mem0ai`` (imports as ``mem0``).
|
||||
specs = ld.LAZY_DEPS["memory.mem0"]
|
||||
assert any(ld._pkg_name_from_spec(s) == "mem0ai" for s in specs)
|
||||
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_unknown_feature_would_raise_without_entry(self, feature, monkeypatch):
|
||||
# Demonstrate the failure mode the allowlist entry prevents: a feature
|
||||
# NOT in LAZY_DEPS raises rather than installing.
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
|
||||
ld.ensure(feature + ".typo", prompt=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Import sites call ensure().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSupermemoryEnsureCalled:
|
||||
def test_client_construction_calls_ensure(self, monkeypatch):
|
||||
"""_SupermemoryClient.__init__ must call ensure('memory.supermemory')
|
||||
before importing the SDK."""
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
ld, "ensure",
|
||||
lambda feature, **kw: calls.append((feature, kw)),
|
||||
)
|
||||
|
||||
# Stub the SDK so construction doesn't need the real package. The
|
||||
# client does ``from supermemory import Supermemory`` right after
|
||||
# ensure(); inject a fake module.
|
||||
import sys
|
||||
import types
|
||||
|
||||
fake = types.ModuleType("supermemory")
|
||||
fake.Supermemory = lambda **kw: object()
|
||||
monkeypatch.setitem(sys.modules, "supermemory", fake)
|
||||
|
||||
_SupermemoryClient(api_key="k", timeout=5.0, container_tag="hermes")
|
||||
|
||||
assert ("memory.supermemory", {"prompt": False}) in calls, (
|
||||
"supermemory client did not call ensure('memory.supermemory', "
|
||||
f"prompt=False); calls={calls}"
|
||||
)
|
||||
|
||||
|
||||
class TestMem0EnsureCalled:
|
||||
def test_create_backend_calls_ensure(self, monkeypatch):
|
||||
"""SupermemoryMemoryProvider-style mem0 provider must call
|
||||
ensure('memory.mem0') in _create_backend before importing the SDK."""
|
||||
from plugins.memory.mem0 import Mem0MemoryProvider
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
ld, "ensure",
|
||||
lambda feature, **kw: calls.append((feature, kw)),
|
||||
)
|
||||
|
||||
prov = Mem0MemoryProvider()
|
||||
# Platform mode is the default; force a known mode and stub the backend
|
||||
# import so we isolate the ensure() call.
|
||||
prov._mode = "platform"
|
||||
prov._api_key = "k"
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
fake = types.ModuleType("mem0")
|
||||
fake.MemoryClient = lambda **kw: object()
|
||||
fake.Memory = object
|
||||
monkeypatch.setitem(sys.modules, "mem0", fake)
|
||||
# _backend imports ``from mem0 import MemoryClient`` lazily inside
|
||||
# PlatformBackend.__init__, so the fake module satisfies it.
|
||||
|
||||
prov._create_backend()
|
||||
|
||||
assert ("memory.mem0", {"prompt": False}) in calls, (
|
||||
f"mem0 _create_backend did not call ensure('memory.mem0', "
|
||||
f"prompt=False); calls={calls}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. supermemory is_available() chicken-and-egg fix.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSupermemoryIsAvailable:
|
||||
def test_available_with_key_even_when_sdk_absent(self, monkeypatch):
|
||||
"""With the key set but the SDK not importable, is_available() must
|
||||
still return True — otherwise the provider never loads on a sealed
|
||||
venv and ensure() (which installs the SDK) never runs."""
|
||||
from plugins.memory.supermemory import SupermemoryMemoryProvider
|
||||
import builtins
|
||||
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "sk-test")
|
||||
|
||||
# Make any attempt to import the SDK fail, simulating the
|
||||
# not-yet-installed sealed-venv state.
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _no_supermemory(name, *args, **kwargs):
|
||||
if name == "supermemory" or name.startswith("supermemory."):
|
||||
raise ImportError("No module named 'supermemory'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _no_supermemory)
|
||||
|
||||
prov = SupermemoryMemoryProvider()
|
||||
assert prov.is_available() is True
|
||||
|
||||
def test_unavailable_without_key(self, monkeypatch):
|
||||
from plugins.memory.supermemory import SupermemoryMemoryProvider
|
||||
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
prov = SupermemoryMemoryProvider()
|
||||
assert prov.is_available() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Real sealed-venv durable-target gate accepts the new features.
|
||||
#
|
||||
# This is the exact hosted-Fly condition: HERMES_DISABLE_LAZY_INSTALLS=1 seals
|
||||
# the venv, but HERMES_LAZY_INSTALL_TARGET redirects installs to a writable
|
||||
# durable dir, so installs are still ALLOWED. We exercise the real
|
||||
# _allow_lazy_installs() + ensure() flow end-to-end with only the pip
|
||||
# subprocess stubbed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSealedVenvDurableTarget:
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_ensure_installs_into_durable_target_on_sealed_venv(
|
||||
self, feature, monkeypatch, tmp_path
|
||||
):
|
||||
# Sealed venv + durable target = the published Docker image config.
|
||||
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
||||
monkeypatch.setenv("HERMES_LAZY_INSTALL_TARGET", str(tmp_path / "lazy"))
|
||||
# config.yaml kill-switch left at default (allow).
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {"allow_lazy_installs": True}},
|
||||
)
|
||||
|
||||
# Real gate must permit installs because a durable target is set.
|
||||
assert ld._allow_lazy_installs() is True, (
|
||||
"sealed venv WITH a durable target must allow installs — this is "
|
||||
"the path honcho/hindsight use on hosted Fly instances"
|
||||
)
|
||||
|
||||
# Drive ensure(): missing first, satisfied after the (stubbed) install.
|
||||
states = iter([False, True])
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states))
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_install(specs, **kw):
|
||||
captured["specs"] = specs
|
||||
captured["target_env"] = os.environ.get("HERMES_LAZY_INSTALL_TARGET")
|
||||
return ld._InstallResult(True, "ok", "")
|
||||
|
||||
monkeypatch.setattr(ld, "_venv_pip_install", fake_install)
|
||||
|
||||
ld.ensure(feature, prompt=False) # must not raise
|
||||
|
||||
assert captured.get("specs") == ld.LAZY_DEPS[feature]
|
||||
assert captured.get("target_env"), (
|
||||
"install ran without the durable target env set"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_sealed_venv_without_target_blocks(self, feature, monkeypatch):
|
||||
# Sealed venv and NO durable target → installs blocked (can't mutate
|
||||
# the sealed venv). Belt-and-suspenders: confirms the gate still
|
||||
# protects the seal for these features.
|
||||
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
||||
monkeypatch.delenv("HERMES_LAZY_INSTALL_TARGET", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {"allow_lazy_installs": True}},
|
||||
)
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
|
||||
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
|
||||
ld.ensure(feature, prompt=False)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import agent.file_safety as fs
|
||||
|
||||
from plugins.memory.retaindb import RetainDBMemoryProvider
|
||||
|
||||
|
||||
def test_upload_file_rejects_hermes_credential_store(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes_home"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"OPENAI_API_KEY":"sk-test-secret"}', encoding="utf-8")
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
|
||||
|
||||
provider = RetainDBMemoryProvider()
|
||||
provider._client = MagicMock()
|
||||
|
||||
result = provider._dispatch("retaindb_upload_file", {"local_path": str(auth_json)})
|
||||
|
||||
assert "error" in result
|
||||
assert "credential store" in result["error"]
|
||||
provider._client.upload_file.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_file_allows_regular_file(tmp_path):
|
||||
note = tmp_path / "note.md"
|
||||
note.write_text("# Note\n", encoding="utf-8")
|
||||
provider = RetainDBMemoryProvider()
|
||||
provider._client = MagicMock()
|
||||
provider._client.upload_file.return_value = {
|
||||
"file": {"id": "file-1", "name": "note.md"},
|
||||
}
|
||||
|
||||
result = provider._dispatch("retaindb_upload_file", {"local_path": str(note)})
|
||||
|
||||
provider._client.upload_file.assert_called_once()
|
||||
assert provider._client.upload_file.call_args.args[0] == note.read_bytes()
|
||||
assert result["file"]["id"] == "file-1"
|
||||
@@ -0,0 +1,625 @@
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.supermemory import (
|
||||
SupermemoryMemoryProvider,
|
||||
_clean_text_for_capture,
|
||||
_format_connection_summary,
|
||||
_format_prefetch_context,
|
||||
_load_supermemory_config,
|
||||
_probe_supermemory_connection,
|
||||
_save_supermemory_config,
|
||||
)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.container_tag = container_tag
|
||||
self.search_mode = search_mode
|
||||
self.add_calls = []
|
||||
self.search_results = []
|
||||
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
|
||||
self.ingest_calls = []
|
||||
self.forgotten_ids = []
|
||||
self.forget_by_query_response = {"success": True, "message": "Forgot"}
|
||||
|
||||
def add_memory(self, content, metadata=None, *, entity_context="",
|
||||
container_tag=None, custom_id=None):
|
||||
self.add_calls.append({
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
"entity_context": entity_context,
|
||||
"container_tag": container_tag,
|
||||
"custom_id": custom_id,
|
||||
})
|
||||
return {"id": "mem_123"}
|
||||
|
||||
def search_memories(self, query, *, limit=5, container_tag=None, search_mode=None):
|
||||
return self.search_results
|
||||
|
||||
def get_profile(self, query=None, *, container_tag=None):
|
||||
return self.profile_response
|
||||
|
||||
def forget_memory(self, memory_id, *, container_tag=None):
|
||||
self.forgotten_ids.append(memory_id)
|
||||
|
||||
def forget_by_query(self, query, *, container_tag=None):
|
||||
return self.forget_by_query_response
|
||||
|
||||
def ingest_conversation(self, session_id, messages, metadata=None):
|
||||
self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("session-1", hermes_home=str(tmp_path), platform="cli")
|
||||
return p
|
||||
|
||||
|
||||
def test_is_available_false_without_api_key(monkeypatch):
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
p = SupermemoryMemoryProvider()
|
||||
assert p.is_available() is False
|
||||
|
||||
|
||||
def test_is_available_true_when_import_missing_but_key_set(monkeypatch):
|
||||
# Regression: is_available() must NOT gate on the supermemory SDK being
|
||||
# importable. The SDK is lazy-installed at client construction (see
|
||||
# _SupermemoryClient.__init__ -> tools.lazy_deps.ensure). Gating here is a
|
||||
# chicken-and-egg trap: on a sealed Docker venv the package isn't present
|
||||
# until ensure() runs, but ensure() only runs once the provider loads —
|
||||
# which this gates. So with the key set and the SDK absent, the provider
|
||||
# must still report available. Mirrors honcho/mem0 (config-presence only).
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "supermemory" or name.startswith("supermemory."):
|
||||
raise ImportError("missing")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
p = SupermemoryMemoryProvider()
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
def test_is_available_false_without_key(monkeypatch):
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
p = SupermemoryMemoryProvider()
|
||||
assert p.is_available() is False
|
||||
|
||||
|
||||
def test_load_and_save_config_round_trip(tmp_path):
|
||||
_save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path))
|
||||
cfg = _load_supermemory_config(str(tmp_path))
|
||||
# container_tag is kept raw — sanitization happens in initialize() after template resolution
|
||||
assert cfg["container_tag"] == "demo-tag"
|
||||
assert cfg["auto_capture"] is False
|
||||
assert cfg["auto_recall"] is True
|
||||
|
||||
|
||||
def test_clean_text_for_capture_strips_injected_context():
|
||||
text = "hello\n<supermemory-context>ignore me</supermemory-context>\nworld"
|
||||
assert _clean_text_for_capture(text) == "hello\nworld"
|
||||
|
||||
|
||||
def test_format_prefetch_context_deduplicates_overlap():
|
||||
result = _format_prefetch_context(
|
||||
static_facts=["Jordan prefers short answers"],
|
||||
dynamic_facts=["Jordan prefers short answers", "Uses Hermes"],
|
||||
search_results=[{"memory": "Uses Hermes", "similarity": 0.9}],
|
||||
max_results=10,
|
||||
)
|
||||
assert result.count("Jordan prefers short answers") == 1
|
||||
assert result.count("Uses Hermes") == 1
|
||||
assert "<supermemory-context>" in result
|
||||
|
||||
|
||||
def test_prefetch_includes_profile_on_first_turn(provider):
|
||||
provider._client.profile_response = {
|
||||
"static": ["Jordan prefers short answers"],
|
||||
"dynamic": ["Current project is Supermemory provider"],
|
||||
"search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
|
||||
}
|
||||
provider.on_turn_start(1, "start")
|
||||
result = provider.prefetch("what am I working on?")
|
||||
assert "User Profile (Persistent)" in result
|
||||
assert "Recent Context" in result
|
||||
assert "Relevant Memories" in result
|
||||
|
||||
|
||||
def test_prefetch_skips_profile_between_frequency(provider):
|
||||
provider._client.profile_response = {
|
||||
"static": ["Jordan prefers short answers"],
|
||||
"dynamic": ["Current project is Supermemory provider"],
|
||||
"search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
|
||||
}
|
||||
provider.on_turn_start(2, "next")
|
||||
result = provider.prefetch("what am I working on?")
|
||||
assert "Relevant Memories" in result
|
||||
assert "User Profile (Persistent)" not in result
|
||||
|
||||
|
||||
def test_sync_turn_buffers_short_messages(provider):
|
||||
# Trivial filtering is no longer applied at sync time — every non-empty turn
|
||||
# is buffered and only the full session is written at session boundaries.
|
||||
provider.sync_turn("ok", "sure", session_id="session-1")
|
||||
assert provider._session_turns == [{"user": "ok", "assistant": "sure"}]
|
||||
assert provider._client.add_calls == []
|
||||
|
||||
|
||||
def test_sync_turn_buffers_cleaned_exchange(provider):
|
||||
provider.sync_turn(
|
||||
"Please remember this\n<supermemory-context>ignore</supermemory-context>",
|
||||
"Got it, storing the context",
|
||||
session_id="session-1",
|
||||
)
|
||||
assert len(provider._session_turns) == 1
|
||||
turn = provider._session_turns[0]
|
||||
assert "ignore" not in turn["user"]
|
||||
assert turn["user"].startswith("Please remember this")
|
||||
assert turn["assistant"] == "Got it, storing the context"
|
||||
# Buffering only — no per-turn writes to the client
|
||||
assert provider._client.add_calls == []
|
||||
assert provider._client.ingest_calls == []
|
||||
|
||||
|
||||
def test_on_session_end_ingests_clean_messages(provider):
|
||||
messages = [
|
||||
{"role": "system", "content": "skip"},
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
provider.on_session_end(messages)
|
||||
assert len(provider._client.ingest_calls) == 1
|
||||
payload = provider._client.ingest_calls[0]
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert payload["messages"] == [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
assert payload["metadata"]["type"] == "full_session"
|
||||
assert payload["metadata"]["session_id"] == "session-1"
|
||||
assert payload["metadata"]["message_count"] == 2
|
||||
# Buffer is cleared after a normal session-end ingest.
|
||||
assert provider._session_turns == []
|
||||
|
||||
|
||||
def test_merge_metadata_stamps_sm_source():
|
||||
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
|
||||
# app (functional routing, not telemetry) — must always be present.
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
client = _SupermemoryClient.__new__(_SupermemoryClient)
|
||||
merged = client._merge_metadata({"type": "explicit_memory"})
|
||||
assert merged["sm_source"] == "hermes"
|
||||
assert merged["type"] == "explicit_memory"
|
||||
|
||||
# Legacy "source" is migrated into "type" when type is absent.
|
||||
merged2 = client._merge_metadata({"source": "conversation_turn"})
|
||||
assert merged2["sm_source"] == "hermes"
|
||||
assert merged2["type"] == "conversation_turn"
|
||||
assert "source" not in merged2
|
||||
|
||||
|
||||
def test_on_memory_write_tracks_thread(provider):
|
||||
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
|
||||
assert provider._write_thread is not None
|
||||
provider._write_thread.join(timeout=1)
|
||||
assert len(provider._client.add_calls) == 1
|
||||
assert provider._client.add_calls[0]["metadata"]["type"] == "explicit_memory"
|
||||
|
||||
|
||||
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch):
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_add_memory(content, metadata=None, *, entity_context="",
|
||||
container_tag=None, custom_id=None):
|
||||
started.set()
|
||||
release.wait(timeout=1)
|
||||
provider._client.add_calls.append({
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
"entity_context": entity_context,
|
||||
})
|
||||
return {"id": "mem_slow"}
|
||||
|
||||
monkeypatch.setattr(provider._client, "add_memory", slow_add_memory)
|
||||
|
||||
# sync_turn now only buffers — no thread is spawned.
|
||||
provider.sync_turn(
|
||||
"Please remember this request in long-term memory",
|
||||
"Absolutely, I will keep that in long-term memory.",
|
||||
session_id="session-1",
|
||||
)
|
||||
assert provider._sync_thread is None
|
||||
assert len(provider._session_turns) == 1
|
||||
|
||||
# on_memory_write still runs on a background thread.
|
||||
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
|
||||
assert started.wait(timeout=1)
|
||||
assert provider._write_thread is not None
|
||||
|
||||
release.set()
|
||||
provider.shutdown()
|
||||
|
||||
# All tracked threads joined and cleared.
|
||||
assert provider._sync_thread is None
|
||||
assert provider._write_thread is None
|
||||
assert provider._prefetch_thread is None
|
||||
# Explicit memory write went through.
|
||||
assert len(provider._client.add_calls) == 1
|
||||
# Buffered turn was flushed as a partial full-session ingest.
|
||||
assert len(provider._client.ingest_calls) == 1
|
||||
payload = provider._client.ingest_calls[0]
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert payload["metadata"]["partial"] is True
|
||||
assert payload["metadata"]["type"] == "full_session"
|
||||
|
||||
|
||||
def test_store_tool_returns_saved_payload(provider):
|
||||
result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"}))
|
||||
assert result["saved"] is True
|
||||
assert result["id"] == "mem_123"
|
||||
|
||||
|
||||
def test_search_tool_formats_results(provider):
|
||||
provider._client.search_results = [
|
||||
{"id": "m1", "memory": "Jordan likes concise docs", "similarity": 0.92}
|
||||
]
|
||||
result = json.loads(provider.handle_tool_call("supermemory_search", {"query": "concise docs"}))
|
||||
assert result["count"] == 1
|
||||
assert result["results"][0]["similarity"] == 92
|
||||
|
||||
|
||||
def test_forget_tool_by_id(provider):
|
||||
result = json.loads(provider.handle_tool_call("supermemory_forget", {"id": "m1"}))
|
||||
assert result == {"forgotten": True, "id": "m1"}
|
||||
assert provider._client.forgotten_ids == ["m1"]
|
||||
|
||||
|
||||
def test_forget_tool_by_query(provider):
|
||||
provider._client.forget_by_query_response = {"success": True, "message": "Forgot one", "id": "m7"}
|
||||
result = json.loads(provider.handle_tool_call("supermemory_forget", {"query": "that thing"}))
|
||||
assert result["success"] is True
|
||||
assert result["id"] == "m7"
|
||||
|
||||
|
||||
def test_profile_tool_formats_sections(provider):
|
||||
provider._client.profile_response = {
|
||||
"static": ["Jordan prefers concise docs"],
|
||||
"dynamic": ["Working on Supermemory provider"],
|
||||
"search_results": [],
|
||||
}
|
||||
result = json.loads(provider.handle_tool_call("supermemory_profile", {}))
|
||||
assert result["static_count"] == 1
|
||||
assert result["dynamic_count"] == 1
|
||||
assert "User Profile (Persistent)" in result["profile"]
|
||||
|
||||
|
||||
def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch):
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
p = SupermemoryMemoryProvider()
|
||||
result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# -- Identity template tests --------------------------------------------------
|
||||
|
||||
|
||||
def test_identity_template_resolved_in_container_tag(monkeypatch, tmp_path):
|
||||
"""container_tag with {identity} resolves to profile-scoped tag."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli", agent_identity="coder")
|
||||
assert p._container_tag == "hermes_coder"
|
||||
|
||||
|
||||
def test_identity_template_default_profile(monkeypatch, tmp_path):
|
||||
"""Without agent_identity kwarg, {identity} resolves to 'default'."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._container_tag == "hermes_default"
|
||||
|
||||
|
||||
def test_container_tag_env_var_override(monkeypatch, tmp_path):
|
||||
"""SUPERMEMORY_CONTAINER_TAG env var overrides config."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setenv("SUPERMEMORY_CONTAINER_TAG", "env-override")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._container_tag == "env_override"
|
||||
|
||||
|
||||
# -- Search mode tests --------------------------------------------------------
|
||||
|
||||
|
||||
def test_search_mode_config_passed_to_client(monkeypatch, tmp_path):
|
||||
"""search_mode from config is passed to _SupermemoryClient."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"search_mode": "memories"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._search_mode == "memories"
|
||||
assert p._client.search_mode == "memories"
|
||||
|
||||
|
||||
def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
|
||||
"""Invalid search_mode falls back to 'hybrid'."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"search_mode": "invalid_mode"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._search_mode == "hybrid"
|
||||
|
||||
|
||||
# -- Multi-container tests ----------------------------------------------------
|
||||
|
||||
|
||||
def test_multi_container_disabled_by_default(provider):
|
||||
"""Multi-container is off by default; schemas have no container_tag param."""
|
||||
assert provider._enable_custom_containers is False
|
||||
schemas = provider.get_tool_schemas()
|
||||
for s in schemas:
|
||||
assert "container_tag" not in s["parameters"]["properties"]
|
||||
|
||||
|
||||
def test_multi_container_enabled_adds_schema_param(monkeypatch, tmp_path):
|
||||
"""When enabled, tool schemas include container_tag parameter."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["project-alpha", "shared"],
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._enable_custom_containers is True
|
||||
assert p._allowed_containers == ["hermes", "project_alpha", "shared"]
|
||||
schemas = p.get_tool_schemas()
|
||||
for s in schemas:
|
||||
assert "container_tag" in s["parameters"]["properties"]
|
||||
|
||||
|
||||
def test_multi_container_tool_store_with_custom_tag(monkeypatch, tmp_path):
|
||||
"""supermemory_store uses the resolved container_tag when multi-container is enabled."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["project-alpha"],
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
result = json.loads(p.handle_tool_call("supermemory_store", {
|
||||
"content": "test memory",
|
||||
"container_tag": "project-alpha",
|
||||
}))
|
||||
assert result["saved"] is True
|
||||
assert result["container_tag"] == "project_alpha"
|
||||
assert p._client.add_calls[-1]["container_tag"] == "project_alpha"
|
||||
|
||||
|
||||
def test_multi_container_rejects_unlisted_tag(monkeypatch, tmp_path):
|
||||
"""Tool calls with a non-whitelisted container_tag return an error."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["allowed-tag"],
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
result = json.loads(p.handle_tool_call("supermemory_store", {
|
||||
"content": "test",
|
||||
"container_tag": "forbidden-tag",
|
||||
}))
|
||||
assert "error" in result
|
||||
assert "not allowed" in result["error"]
|
||||
|
||||
|
||||
def test_multi_container_system_prompt_includes_instructions(monkeypatch, tmp_path):
|
||||
"""system_prompt_block includes container list and instructions when multi-container is enabled."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["docs"],
|
||||
"custom_container_instructions": "Use docs for documentation context.",
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
block = p.system_prompt_block()
|
||||
assert "Multi-container mode enabled" in block
|
||||
assert "docs" in block
|
||||
assert "Use docs for documentation context." in block
|
||||
|
||||
|
||||
def test_get_config_schema_minimal():
|
||||
"""get_config_schema only returns the API key field."""
|
||||
p = SupermemoryMemoryProvider()
|
||||
schema = p.get_config_schema()
|
||||
assert len(schema) == 1
|
||||
assert schema[0]["key"] == "api_key"
|
||||
assert schema[0]["secret"] is True
|
||||
|
||||
|
||||
def test_format_connection_summary_ok():
|
||||
summary = _format_connection_summary({
|
||||
"ok": True,
|
||||
"container_tag": "hermes_coder",
|
||||
"profile_facts": 12,
|
||||
"auto_recall": True,
|
||||
"auto_capture": False,
|
||||
})
|
||||
assert "✓ Connected" in summary
|
||||
assert "container: hermes_coder" in summary
|
||||
assert "12 profile facts" in summary
|
||||
assert "auto_recall on" in summary
|
||||
assert "auto_capture off" in summary
|
||||
|
||||
|
||||
def test_format_connection_summary_single_fact_and_error():
|
||||
one = _format_connection_summary({
|
||||
"ok": True,
|
||||
"container_tag": "hermes",
|
||||
"profile_facts": 1,
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
})
|
||||
assert "1 profile fact" in one
|
||||
assert "1 profile facts" not in one
|
||||
|
||||
err = _format_connection_summary({
|
||||
"ok": False,
|
||||
"error": "invalid API key",
|
||||
"container_tag": "hermes",
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
})
|
||||
assert "✗ invalid API key" in err
|
||||
assert "container: hermes" in err
|
||||
|
||||
|
||||
def test_probe_supermemory_connection_missing_key(tmp_path):
|
||||
status = _probe_supermemory_connection("", str(tmp_path))
|
||||
assert status["ok"] is False
|
||||
assert status["error"] == "SUPERMEMORY_API_KEY not set"
|
||||
assert status["container_tag"] == "hermes"
|
||||
|
||||
|
||||
def _stub_supermemory_importable(monkeypatch):
|
||||
"""Make ``__import__("supermemory")`` succeed without the real package.
|
||||
|
||||
``_probe_supermemory_connection`` guards on ``__import__("supermemory")``
|
||||
before using the (mocked) client, so tests that mock ``_SupermemoryClient``
|
||||
must also satisfy that import guard — otherwise they only pass in an
|
||||
environment where the optional ``supermemory`` package happens to be
|
||||
installed (and fail on a clean checkout / CI). Mirrors the inverse stub in
|
||||
``test_is_available_false_when_import_missing``.
|
||||
"""
|
||||
import builtins
|
||||
import types
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "supermemory":
|
||||
return types.ModuleType("supermemory")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
|
||||
def test_probe_supermemory_connection_success(monkeypatch, tmp_path):
|
||||
_stub_supermemory_importable(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
|
||||
class CountingClient(FakeClient):
|
||||
def get_profile(self, query=None, *, container_tag=None):
|
||||
return {
|
||||
"static": ["Prefers TypeScript"],
|
||||
"dynamic": ["", "Working on Hermes"],
|
||||
"search_results": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", CountingClient)
|
||||
status = _probe_supermemory_connection("test-key", str(tmp_path))
|
||||
assert status["ok"] is True
|
||||
assert status["profile_facts"] == 2
|
||||
assert status["auto_recall"] is True
|
||||
|
||||
|
||||
def test_probe_supermemory_connection_client_error(monkeypatch, tmp_path):
|
||||
_stub_supermemory_importable(monkeypatch)
|
||||
|
||||
class BrokenClient(FakeClient):
|
||||
def get_profile(self, query=None, *, container_tag=None):
|
||||
raise RuntimeError("API unavailable")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", BrokenClient)
|
||||
status = _probe_supermemory_connection("test-key", str(tmp_path))
|
||||
assert status["ok"] is False
|
||||
assert "API unavailable" in status["error"]
|
||||
|
||||
|
||||
def test_get_status_config_returns_summary(monkeypatch, tmp_path):
|
||||
_stub_supermemory_importable(monkeypatch)
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
monkeypatch.setattr(
|
||||
"hermes_constants.get_hermes_home",
|
||||
lambda: tmp_path,
|
||||
)
|
||||
result = SupermemoryMemoryProvider().get_status_config({})
|
||||
assert "summary" in result
|
||||
assert "✓ Connected" in result["summary"]
|
||||
assert "container: hermes" in result["summary"]
|
||||
|
||||
|
||||
def test_post_setup_writes_config_and_prints_summary(monkeypatch, tmp_path, capsys):
|
||||
config: dict = {"memory": {}}
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.memory_setup._prompt",
|
||||
lambda label, secret=True, default=None: "new-api-key",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.supermemory._probe_supermemory_connection",
|
||||
lambda api_key, hermes_home, **kwargs: {
|
||||
"ok": True,
|
||||
"container_tag": "hermes",
|
||||
"profile_facts": 3,
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
},
|
||||
)
|
||||
|
||||
saved: dict = {}
|
||||
|
||||
def fake_save_config(cfg):
|
||||
saved.update(cfg)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", fake_save_config)
|
||||
|
||||
SupermemoryMemoryProvider().post_setup(str(tmp_path), config)
|
||||
|
||||
assert config["memory"]["provider"] == "supermemory"
|
||||
assert saved["memory"]["provider"] == "supermemory"
|
||||
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
|
||||
assert "SUPERMEMORY_API_KEY=new-api-key" in env_text
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "✓ Connected" in out
|
||||
assert "3 profile facts" in out
|
||||
assert "Memory provider: supermemory" in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
|
||||
def test_save_config_sets_owner_only_permissions(tmp_path):
|
||||
"""supermemory.json must be written with 0o600 so API key is not world-readable."""
|
||||
_save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path))
|
||||
config_file = tmp_path / "supermemory.json"
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Unit tests for the custom provider profile's reasoning wiring.
|
||||
|
||||
``provider=custom`` covers any OpenAI-compatible endpoint the user points
|
||||
Hermes at — local Ollama, vLLM, llama.cpp, and hosted reasoning APIs like
|
||||
GLM-5.2 on Volcengine ARK. Before #57601's salvage, ``CustomProfile`` emitted
|
||||
nothing when reasoning was *enabled*, so a configured ``reasoning_effort``
|
||||
was silently dropped for every custom endpoint.
|
||||
|
||||
These tests pin the wire-shape contract:
|
||||
- disabled → extra_body.think = False
|
||||
- enabled + effort → top-level reasoning_effort (native OpenAI-compat
|
||||
format GLM/ARK expect), passed through verbatim
|
||||
including ``max``/``xhigh``
|
||||
- enabled + no effort → nothing emitted (endpoint's server default applies)
|
||||
- ollama_num_ctx → extra_body.options.num_ctx, orthogonal to reasoning
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_profile():
|
||||
"""Resolve the registered custom profile via the global registry.
|
||||
|
||||
Importing ``model_tools`` triggers plugin discovery, which registers the
|
||||
``custom`` profile. Going through ``get_provider_profile`` keeps the test
|
||||
honest — if the registered class is ever downgraded to a plain
|
||||
``ProviderProfile``, the assertions below collapse.
|
||||
"""
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("custom")
|
||||
assert profile is not None, "custom provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestCustomReasoningWireShape:
|
||||
"""``build_api_kwargs_extras`` produces the correct wire format."""
|
||||
|
||||
def test_no_reasoning_config_emits_nothing(self, custom_profile):
|
||||
"""Unset reasoning → omit everything so the endpoint's default applies."""
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None, model="glm-5.2"
|
||||
)
|
||||
assert eb == {}
|
||||
assert tl == {}
|
||||
|
||||
def test_disabled_sends_think_false(self, custom_profile):
|
||||
"""enabled=False → extra_body.think = False (Ollama thinking-off flag)."""
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False}, model="glm-5.2"
|
||||
)
|
||||
assert eb == {"think": False}
|
||||
assert tl == {}
|
||||
|
||||
def test_effort_none_sends_think_false(self, custom_profile):
|
||||
"""effort='none' is the disable alias → think=False, no effort."""
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "none"}, model="glm-5.2"
|
||||
)
|
||||
assert eb == {"think": False}
|
||||
assert tl == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"effort", ["minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
)
|
||||
def test_enabled_effort_goes_top_level(self, custom_profile, effort):
|
||||
"""enabled + effort → TOP-LEVEL reasoning_effort, passed through verbatim.
|
||||
|
||||
GLM-5.2/ARK and OpenAI-compatible reasoning APIs read reasoning_effort
|
||||
as a top-level string, not nested in extra_body. ``max`` is GLM's
|
||||
native deep-reasoning level and must survive.
|
||||
"""
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort}, model="glm-5.2"
|
||||
)
|
||||
assert tl == {"reasoning_effort": effort}
|
||||
assert "reasoning_effort" not in eb
|
||||
assert "think" not in eb
|
||||
|
||||
def test_enabled_without_effort_emits_nothing(self, custom_profile):
|
||||
"""enabled but no effort → omit; do NOT force a level the user didn't pick."""
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True}, model="glm-5.2"
|
||||
)
|
||||
assert eb == {}
|
||||
assert tl == {}
|
||||
|
||||
def test_does_not_force_think_true_on_enable(self, custom_profile):
|
||||
"""We must never send think=True on enable — it's Ollama-only and
|
||||
would 400 on GLM/vLLM endpoints that don't recognize it."""
|
||||
eb, _ = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"}, model="glm-5.2"
|
||||
)
|
||||
assert eb.get("think") is not True
|
||||
|
||||
|
||||
class TestCustomReasoningWithNumCtx:
|
||||
"""Ollama num_ctx and reasoning are independent and compose."""
|
||||
|
||||
def test_num_ctx_alone(self, custom_profile):
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None, ollama_num_ctx=8192, model="qwen3"
|
||||
)
|
||||
assert eb == {"options": {"num_ctx": 8192}}
|
||||
assert tl == {}
|
||||
|
||||
def test_num_ctx_with_effort(self, custom_profile):
|
||||
eb, tl = custom_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
ollama_num_ctx=8192,
|
||||
model="qwen3",
|
||||
)
|
||||
assert eb == {"options": {"num_ctx": 8192}}
|
||||
assert tl == {"reasoning_effort": "high"}
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Unit tests for the DeepSeek provider profile's thinking-mode wiring.
|
||||
|
||||
DeepSeek V4 (and the legacy ``deepseek-reasoner``) expects every request to
|
||||
carry an explicit ``extra_body.thinking`` parameter. Omitting it makes the
|
||||
server default to thinking-mode ON, which then enforces the
|
||||
``reasoning_content``-must-be-echoed-back contract on subsequent turns and
|
||||
breaks the conversation with HTTP 400 (#15700, #17212, #17825).
|
||||
|
||||
These tests pin the profile's wire-shape contract so DeepSeek requests stay
|
||||
correctly shaped without going live.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deepseek_profile():
|
||||
"""Resolve the registered DeepSeek profile.
|
||||
|
||||
Going through ``providers.get_provider_profile`` keeps the test honest —
|
||||
if someone later replaces the registered class with a plain
|
||||
``ProviderProfile``, every assertion below collapses.
|
||||
"""
|
||||
# ``model_tools`` triggers plugin discovery on import, which is what
|
||||
# registers the DeepSeek profile in the global provider registry.
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("deepseek")
|
||||
assert profile is not None, "deepseek provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestDeepSeekThinkingWireShape:
|
||||
"""``build_api_kwargs_extras`` produces DeepSeek's exact wire format."""
|
||||
|
||||
def test_v4_pro_default_enables_thinking_without_effort(self, deepseek_profile):
|
||||
"""No reasoning_config → thinking enabled, server picks default effort."""
|
||||
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None, model="deepseek-v4-pro"
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_v4_pro_enabled_with_high_effort(self, deepseek_profile):
|
||||
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
|
||||
def test_standard_efforts_pass_through(self, deepseek_profile, effort):
|
||||
_, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert top_level == {"reasoning_effort": effort}
|
||||
|
||||
@pytest.mark.parametrize("effort", ["xhigh", "max", "MAX", " Max "])
|
||||
def test_xhigh_and_max_normalize_to_max(self, deepseek_profile, effort):
|
||||
_, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
def test_explicitly_disabled_sends_disabled_marker(self, deepseek_profile):
|
||||
"""``reasoning_config.enabled=False`` → ``thinking.type=disabled``.
|
||||
|
||||
The crucial bit is that the parameter is *sent* at all — DeepSeek
|
||||
defaults to thinking-on when ``thinking`` is absent.
|
||||
"""
|
||||
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False}, model="deepseek-v4-pro"
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
# No effort when disabled — DeepSeek rejects it.
|
||||
assert top_level == {}
|
||||
|
||||
def test_disabled_ignores_effort_field(self, deepseek_profile):
|
||||
"""Effort silently dropped when thinking is off."""
|
||||
_, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
def test_unknown_effort_omits_top_level(self, deepseek_profile):
|
||||
"""Garbage effort → omit reasoning_effort so DeepSeek applies its default."""
|
||||
_, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "garbage"},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
def test_empty_effort_omits_top_level(self, deepseek_profile):
|
||||
_, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": ""},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestDeepSeekModelGating:
|
||||
"""V4 family + ``deepseek-reasoner`` get thinking; V3 stays untouched."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-future-variant",
|
||||
"deepseek-reasoner",
|
||||
"DEEPSEEK-V4-PRO", # case-insensitive
|
||||
],
|
||||
)
|
||||
def test_thinking_capable_models_emit_thinking(self, deepseek_profile, model):
|
||||
extra_body, _ = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None, model=model
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"deepseek-chat", # V3 alias
|
||||
"deepseek-v3-0324", # explicit V3
|
||||
"deepseek-v3.1", # V3 minor revisions
|
||||
"", # bare/unknown
|
||||
None, # missing
|
||||
"deepseek-unknown", # unrecognized
|
||||
],
|
||||
)
|
||||
def test_non_thinking_models_emit_nothing(self, deepseek_profile, model):
|
||||
extra_body, top_level = deepseek_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"}, model=model
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestDeepSeekFullKwargsIntegration:
|
||||
"""End-to-end: the transport's full kwargs match DeepSeek's live wire format.
|
||||
|
||||
The live test harness in ``tests/run_agent/test_deepseek_v4_thinking_live.py``
|
||||
sends ``{"reasoning_effort": "high", "extra_body": {"thinking": {"type":
|
||||
"enabled"}}}``. Confirm the transport produces that exact shape when wired
|
||||
through the registered DeepSeek profile.
|
||||
"""
|
||||
|
||||
def test_full_kwargs_match_live_wire_shape(self, deepseek_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="deepseek-v4-pro",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=deepseek_profile,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
provider_name="deepseek",
|
||||
)
|
||||
assert kwargs["model"] == "deepseek-v4-pro"
|
||||
assert kwargs["reasoning_effort"] == "high"
|
||||
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
|
||||
def test_v3_chat_full_kwargs_omit_thinking(self, deepseek_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="deepseek-chat",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=deepseek_profile,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
provider_name="deepseek",
|
||||
)
|
||||
assert "reasoning_effort" not in kwargs
|
||||
assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {})
|
||||
|
||||
|
||||
class TestDeepSeekAuxModel:
|
||||
"""DeepSeek aux model is set on the profile so users stop seeing the
|
||||
bogus 'No auxiliary LLM provider configured' warning (#26924).
|
||||
|
||||
Pinned at the profile layer rather than the legacy
|
||||
`_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict — new providers are
|
||||
expected to set `default_aux_model` on `ProviderProfile`, and the
|
||||
fallback dict only exists for providers that predate the profiles
|
||||
system.
|
||||
"""
|
||||
|
||||
def test_profile_advertises_deepseek_chat(self, deepseek_profile):
|
||||
assert deepseek_profile.default_aux_model == "deepseek-chat"
|
||||
|
||||
def test_consumer_api_returns_deepseek_chat(self):
|
||||
from agent.auxiliary_client import _get_aux_model_for_provider
|
||||
assert _get_aux_model_for_provider("deepseek") == "deepseek-chat"
|
||||
|
||||
def test_consumer_api_returns_non_empty(self):
|
||||
from agent.auxiliary_client import _get_aux_model_for_provider
|
||||
assert _get_aux_model_for_provider("deepseek") != ""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for the Fireworks AI provider profile.
|
||||
|
||||
Pins the profile's contract without going live: identity, alias registration,
|
||||
and the pay-as-you-go model defaults (direct catalog ``/models/``
|
||||
IDs, not the router-only tier).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fireworks_profile():
|
||||
"""Resolve the registered Fireworks profile through the real discovery path."""
|
||||
# Importing model_tools triggers plugin discovery, registering the profile.
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("fireworks")
|
||||
assert profile is not None, "fireworks provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestFireworksIdentity:
|
||||
def test_core_fields(self, fireworks_profile):
|
||||
p = fireworks_profile
|
||||
assert p.name == "fireworks"
|
||||
assert p.auth_type == "api_key"
|
||||
assert p.base_url == "https://api.fireworks.ai/inference/v1"
|
||||
assert "FIREWORKS_API_KEY" in p.env_vars
|
||||
assert "FIREWORKS_BASE_URL" not in p.env_vars
|
||||
|
||||
def test_display_metadata_present(self, fireworks_profile):
|
||||
# Prominence copy is surfaced in the picker; keep it non-empty rather
|
||||
# than pinning exact marketing wording (that's expected to change).
|
||||
assert fireworks_profile.display_name
|
||||
assert fireworks_profile.description
|
||||
assert fireworks_profile.signup_url.startswith("https://")
|
||||
|
||||
|
||||
class TestFireworksHeaders:
|
||||
def test_no_partner_attribution_headers(self, fireworks_profile):
|
||||
assert "HTTP-Referer" not in fireworks_profile.default_headers
|
||||
assert "X-Title" not in fireworks_profile.default_headers
|
||||
|
||||
|
||||
class TestFireworksAliases:
|
||||
@pytest.mark.parametrize("alias", ["fireworks-ai", "fw"])
|
||||
def test_alias_resolves_via_registry(self, fireworks_profile, alias):
|
||||
import providers
|
||||
|
||||
resolved = providers.get_provider_profile(alias)
|
||||
assert resolved is not None
|
||||
assert resolved.name == "fireworks"
|
||||
|
||||
def test_aliases_declared_on_profile(self, fireworks_profile):
|
||||
assert "fireworks-ai" in fireworks_profile.aliases
|
||||
assert "fw" in fireworks_profile.aliases
|
||||
|
||||
|
||||
class TestFireworksModelDefaults:
|
||||
"""Defaults must be usable with a standard pay-as-you-go key.
|
||||
|
||||
PAYG keys address ``accounts/fireworks/models/...`` directly; the bundled
|
||||
defaults target that (the BYOK motion) so a fresh key works out of the box,
|
||||
and use the standard tier rather than turbo as the out-of-box default.
|
||||
"""
|
||||
|
||||
def test_aux_model_is_payg_model_not_router(self, fireworks_profile):
|
||||
aux = fireworks_profile.default_aux_model
|
||||
assert aux.startswith("accounts/fireworks/models/"), aux
|
||||
assert "/routers/" not in aux
|
||||
assert "turbo" not in aux.lower()
|
||||
|
||||
def test_fallback_models_are_payg_models_not_routers(self, fireworks_profile):
|
||||
assert fireworks_profile.fallback_models, "expected curated fallbacks"
|
||||
for model in fireworks_profile.fallback_models:
|
||||
assert model.startswith("accounts/fireworks/models/"), model
|
||||
assert "/routers/" not in model
|
||||
assert "turbo" not in model.lower(), model
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Unit tests for the Kimi/Moonshot provider profile's reasoning wiring.
|
||||
|
||||
Moonshot's OpenAI-compat endpoint (``api.moonshot.ai/v1``) treats
|
||||
``extra_body.thinking`` and a top-level ``reasoning_effort`` as mutually
|
||||
exclusive. The profile must send at most one of them — never both — so a
|
||||
request can't trip "cannot specify both 'thinking' and 'reasoning_effort'".
|
||||
|
||||
This mirrors the kimi-k2 handling already shipped for the opencode-go relay
|
||||
(see ``tests/plugins/model_providers/test_opencode_go_profile.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kimi_profile():
|
||||
"""Resolve the registered Kimi profile via the provider registry.
|
||||
|
||||
Importing ``model_tools`` triggers plugin discovery, which registers the
|
||||
Kimi profile. Going through ``get_provider_profile`` keeps the test honest:
|
||||
if the registered class is ever swapped for a plain ``ProviderProfile`` the
|
||||
assertions below collapse.
|
||||
"""
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("kimi-coding")
|
||||
assert profile is not None, "kimi-coding provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestKimiReasoningWireShape:
|
||||
"""``build_api_kwargs_extras`` never emits thinking + reasoning_effort together."""
|
||||
|
||||
def test_no_config_enables_thinking_without_effort(self, kimi_profile):
|
||||
"""No reasoning_config → thinking on, server picks the depth.
|
||||
|
||||
Regression guard: this path previously also sent
|
||||
``reasoning_effort="medium"``, pairing thinking + effort on every
|
||||
default call.
|
||||
"""
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(reasoning_config=None)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
|
||||
def test_explicit_effort_sends_effort_only(self, kimi_profile, effort):
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort}
|
||||
)
|
||||
assert top_level == {"reasoning_effort": effort}
|
||||
assert "thinking" not in extra_body
|
||||
|
||||
def test_enabled_without_effort_falls_back_to_thinking(self, kimi_profile):
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True}
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize("effort", ["", "garbage", "xhigh", "max"])
|
||||
def test_unrecognized_effort_falls_back_to_thinking(self, kimi_profile, effort):
|
||||
"""Unknown/strong efforts aren't in Moonshot's low|medium|high set, so
|
||||
we drop to the thinking toggle rather than sending an invalid effort."""
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort}
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_disabled_sends_thinking_disabled_only(self, kimi_profile):
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False}
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_disabled_ignores_effort(self, kimi_profile):
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"}
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reasoning_config",
|
||||
[
|
||||
None,
|
||||
{"enabled": True},
|
||||
{"enabled": True, "effort": "high"},
|
||||
{"enabled": True, "effort": "garbage"},
|
||||
{"enabled": False},
|
||||
{"enabled": False, "effort": "low"},
|
||||
],
|
||||
)
|
||||
def test_never_emits_both(self, kimi_profile, reasoning_config):
|
||||
"""The core invariant: thinking and reasoning_effort are never both set."""
|
||||
extra_body, top_level = kimi_profile.build_api_kwargs_extras(
|
||||
reasoning_config=reasoning_config
|
||||
)
|
||||
assert not ("thinking" in extra_body and "reasoning_effort" in top_level)
|
||||
|
||||
|
||||
class TestKimiFullKwargsIntegration:
|
||||
"""The transport's full kwargs carry at most one reasoning knob."""
|
||||
|
||||
def _build(self, kimi_profile, reasoning_config):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
return ChatCompletionsTransport().build_kwargs(
|
||||
model="kimi-k2-turbo-preview",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=kimi_profile,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url="https://api.moonshot.ai/v1",
|
||||
provider_name="kimi-coding",
|
||||
)
|
||||
|
||||
def test_explicit_effort_omits_thinking(self, kimi_profile):
|
||||
kwargs = self._build(kimi_profile, {"enabled": True, "effort": "high"})
|
||||
assert kwargs["reasoning_effort"] == "high"
|
||||
assert "thinking" not in kwargs.get("extra_body", {})
|
||||
|
||||
def test_no_config_omits_effort(self, kimi_profile):
|
||||
kwargs = self._build(kimi_profile, None)
|
||||
assert "reasoning_effort" not in kwargs
|
||||
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Unit tests for the MiniMax provider profile.
|
||||
|
||||
Three MiniMax provider profiles (`minimax` direct API, `minimax-cn` China direct
|
||||
API, `minimax-oauth` browser OAuth) all advertise a `default_aux_model` on
|
||||
their `ProviderProfile`. The previous M2.7 / M2.7-highspeed values were
|
||||
stale relative to the current frontier model (M3, released 2026-06-01) and
|
||||
inconsistent with the `_PROVIDER_MODELS["minimax"]` catalog top entry in
|
||||
`hermes_cli/models.py`.
|
||||
|
||||
This file pins the new defaults so the choice is reviewable and any future
|
||||
revert shows up in a failing test rather than silent behavior drift.
|
||||
|
||||
Refs:
|
||||
- Issue #36196: M3 support request
|
||||
- PR #36205 (closed unmerged): Csrayz's M3 + 1M context work
|
||||
- PR #36212 (open): adds M3 to `_PROVIDER_MODELS["minimax"]` catalog
|
||||
- PR #6082: M2.7-highspeed → M2.7 for aux model (half-price fix)
|
||||
- Commit 773a0faca: same profile-layer fix pattern for `deepseek`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(params=["minimax", "minimax-cn", "minimax-oauth"])
|
||||
def minimax_profile(request):
|
||||
"""Resolve each registered MiniMax profile.
|
||||
|
||||
Going through ``providers.get_provider_profile`` keeps the test honest —
|
||||
if someone later replaces the registered class with a plain
|
||||
``ProviderProfile``, every assertion below collapses.
|
||||
"""
|
||||
import model_tools # noqa: F401 -- triggers plugin discovery
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile(request.param)
|
||||
assert profile is not None, f"{request.param} provider profile must be registered"
|
||||
return profile, request.param
|
||||
|
||||
|
||||
class TestMinimaxAuxModelM3:
|
||||
"""MiniMax profile aux model is the new frontier M3, not the stale M2.7.
|
||||
|
||||
The catalog top entry is ``MiniMax-M3`` in
|
||||
``hermes_cli.models._PROVIDER_MODELS['minimax']`` and the
|
||||
user-facing ``model.default`` for a Token-Plan install is M3,
|
||||
so pinning the aux default to the same model keeps the runtime
|
||||
consistent (same auth, same billing pool, same rate limits, no
|
||||
surprise 2x-cost highspeed variant). M3 was released 2026-06-01
|
||||
— picking it as the aux default matches the forward-looking
|
||||
catalog order rather than the pre-M3 era.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_id,expected",
|
||||
[
|
||||
("minimax", "MiniMax-M3"),
|
||||
("minimax-cn", "MiniMax-M3"),
|
||||
# minimax-oauth sticks with M2.7: the OAuth / Coding Plan
|
||||
# tier historically used -highspeed (PR #6082 collapsed that
|
||||
# to plain M2.7 to avoid the 2x TPS surcharge). M3 is not on
|
||||
# the OAuth/Coding Plan tier per platform docs as of this PR,
|
||||
# so the safe choice is the cheapest generally-available
|
||||
# M2.7 — matching PR #6082's intent.
|
||||
("minimax-oauth", "MiniMax-M2.7"),
|
||||
],
|
||||
)
|
||||
def test_profile_advertises_expected_aux_model(
|
||||
self, provider_id, expected
|
||||
):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile(provider_id)
|
||||
assert profile is not None
|
||||
assert profile.default_aux_model == expected, (
|
||||
f"{provider_id} default_aux_model drifted to "
|
||||
f"{profile.default_aux_model!r}, expected {expected!r}"
|
||||
)
|
||||
|
||||
def test_consumer_api_returns_non_empty_for_each_provider(self, minimax_profile):
|
||||
from agent.auxiliary_client import _get_aux_model_for_provider
|
||||
|
||||
profile, provider_id = minimax_profile
|
||||
resolved = _get_aux_model_for_provider(provider_id)
|
||||
assert resolved != "", (
|
||||
f"_get_aux_model_for_provider({provider_id!r}) returned empty — "
|
||||
"the 'No auxiliary LLM provider configured' warning will fire on "
|
||||
f"every {provider_id} session even though the profile advertises "
|
||||
f"default_aux_model={profile.default_aux_model!r}"
|
||||
)
|
||||
assert resolved == profile.default_aux_model, (
|
||||
f"_get_aux_model_for_provider({provider_id!r}) returned "
|
||||
f"{resolved!r} but profile advertises {profile.default_aux_model!r} "
|
||||
"— the consumer API and the profile have drifted out of sync"
|
||||
)
|
||||
|
||||
|
||||
class TestMinimaxAuxModelNotHighspeed:
|
||||
"""Regression guard against re-introducing the M2.7-highspeed aux default.
|
||||
|
||||
PR #6082 collapsed the highspeed aux choice to plain M2.7 because the
|
||||
highspeed variant costs 2x with no real benefit for compression / vision /
|
||||
session-search aux tasks. None of the three MiniMax profiles should
|
||||
silently re-introduce that 2x-cost path.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("provider_id", ["minimax", "minimax-cn", "minimax-oauth"])
|
||||
def test_default_aux_model_is_not_highspeed(self, provider_id):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile(provider_id)
|
||||
assert profile is not None
|
||||
assert "highspeed" not in profile.default_aux_model.lower(), (
|
||||
f"{provider_id} default_aux_model={profile.default_aux_model!r} "
|
||||
"is a -highspeed variant — that costs 2x for the same model and "
|
||||
"broke #4082 the first time. Revert to plain M2.7 or M3."
|
||||
)
|
||||
|
||||
|
||||
class TestMinimaxM3OpenAIReasoningWireShape:
|
||||
"""MiniMax-M3 on api.minimax.io/v1 gets MiniMax's OpenAI-compatible knobs."""
|
||||
|
||||
def test_m3_openai_route_requests_reasoning_split_by_default(self):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("minimax")
|
||||
assert profile is not None
|
||||
extra_body, top_level = profile.build_api_kwargs_extras(
|
||||
reasoning_config=None,
|
||||
model="MiniMax-M3",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
)
|
||||
assert extra_body == {"reasoning_split": True}
|
||||
assert top_level == {}
|
||||
|
||||
def test_m3_openai_route_maps_explicit_effort_to_adaptive_only(self):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("minimax")
|
||||
assert profile is not None
|
||||
extra_body, top_level = profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model="MiniMax-M3",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
)
|
||||
assert extra_body == {
|
||||
"reasoning_split": True,
|
||||
"thinking": {"type": "adaptive"},
|
||||
}
|
||||
assert top_level == {}
|
||||
|
||||
def test_m3_openai_route_does_not_send_reasoning_effort(self):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("minimax")
|
||||
assert profile is not None
|
||||
extra_body, _top_level = profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "xhigh"},
|
||||
model="MiniMax-M3",
|
||||
base_url="https://api.minimax.io/v1/",
|
||||
)
|
||||
assert extra_body == {
|
||||
"reasoning_split": True,
|
||||
"thinking": {"type": "adaptive"},
|
||||
}
|
||||
|
||||
def test_m3_openai_route_can_disable_thinking(self):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("minimax")
|
||||
assert profile is not None
|
||||
extra_body, top_level = profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"},
|
||||
model="MiniMax-M3",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
)
|
||||
assert extra_body == {
|
||||
"reasoning_split": True,
|
||||
"thinking": {"type": "disabled"},
|
||||
}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,base_url",
|
||||
[
|
||||
("MiniMax-M2.7", "https://api.minimax.io/v1"),
|
||||
("MiniMax-M3", "https://api.minimax.io/anthropic"),
|
||||
("MiniMax-M3", "https://api.minimaxi.com/v1"),
|
||||
],
|
||||
)
|
||||
def test_non_m3_or_non_global_openai_routes_emit_no_openai_reasoning_knobs(
|
||||
self, model, base_url
|
||||
):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("minimax")
|
||||
assert profile is not None
|
||||
extra_body, top_level = profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
def test_transport_threads_base_url_to_profile(self):
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
profile = providers.get_provider_profile("minimax")
|
||||
assert profile is not None
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="MiniMax-M3",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=profile,
|
||||
reasoning_config={"enabled": True, "effort": "medium"},
|
||||
base_url="https://api.minimax.io/v1",
|
||||
)
|
||||
assert kwargs["extra_body"] == {
|
||||
"reasoning_split": True,
|
||||
"thinking": {"type": "adaptive"},
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Unit tests for the Ollama Cloud provider profile's reasoning-effort wiring.
|
||||
|
||||
Ollama Cloud's ``/v1/chat/completions`` endpoint supports top-level
|
||||
``reasoning_effort`` with values ``none``, ``low``, ``medium``, ``high``,
|
||||
and (undocumented but empirically confirmed) ``max``. The profile maps
|
||||
Hermes's ``xhigh`` → ``max`` to unlock DeepSeek V4's "Max thinking" tier
|
||||
and passes the standard levels through unchanged.
|
||||
|
||||
These tests pin the profile's wire-shape contract so Ollama Cloud
|
||||
requests carry the correct ``reasoning_effort`` field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ollama_cloud_profile():
|
||||
"""Resolve the registered Ollama Cloud profile.
|
||||
|
||||
Going through ``providers.get_provider_profile`` keeps the test
|
||||
honest — if someone replaces the registered class with a plain
|
||||
``ProviderProfile``, every assertion below collapses.
|
||||
"""
|
||||
# ``model_tools`` triggers plugin discovery on import, which is what
|
||||
# registers the Ollama Cloud profile in the global provider registry.
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("ollama-cloud")
|
||||
assert profile is not None, "ollama-cloud provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestOllamaCloudReasoningEffort:
|
||||
"""``build_api_kwargs_extras`` emits correct top-level ``reasoning_effort``."""
|
||||
|
||||
# ── xhigh / max → max ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("effort", ["xhigh", "max", "MAX", " Max "])
|
||||
def test_xhigh_and_max_normalize_to_max(self, ollama_cloud_profile, effort):
|
||||
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
# ── low / medium / high pass through ───────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
|
||||
def test_standard_efforts_pass_through(self, ollama_cloud_profile, effort):
|
||||
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
)
|
||||
assert top_level == {"reasoning_effort": effort}
|
||||
|
||||
# ── disabled → no reasoning_effort emitted ─────────────────────
|
||||
|
||||
def test_explicitly_disabled_emits_nothing(self, ollama_cloud_profile):
|
||||
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False},
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
def test_disabled_ignores_effort_field(self, ollama_cloud_profile):
|
||||
"""Effort silently dropped when thinking is off."""
|
||||
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"},
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
# ── none effort → no reasoning_effort ──────────────────────────
|
||||
|
||||
def test_none_effort_emits_nothing(self, ollama_cloud_profile):
|
||||
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "none"},
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
# ── missing / empty effort → let model default ─────────────────
|
||||
|
||||
def test_no_reasoning_config_emits_nothing(self, ollama_cloud_profile):
|
||||
extra_body, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None,
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
def test_empty_effort_emits_nothing(self, ollama_cloud_profile):
|
||||
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": ""},
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
def test_no_effort_key_emits_nothing(self, ollama_cloud_profile):
|
||||
"""When effort key is absent, let the model use its default."""
|
||||
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True},
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
# ── unknown effort → forwarded as-is ───────────────────────────
|
||||
|
||||
def test_unknown_effort_forwarded(self, ollama_cloud_profile):
|
||||
_, top_level = ollama_cloud_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "future-tier"},
|
||||
)
|
||||
assert top_level == {"reasoning_effort": "future-tier"}
|
||||
|
||||
|
||||
class TestOllamaCloudFullKwargsIntegration:
|
||||
"""End-to-end: the transport's full kwargs include reasoning_effort."""
|
||||
|
||||
def test_full_kwargs_with_xhigh(self, ollama_cloud_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="deepseek-v4-pro:cloud",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=ollama_cloud_profile,
|
||||
reasoning_config={"enabled": True, "effort": "xhigh"},
|
||||
base_url="https://ollama.com/v1",
|
||||
provider_name="ollama-cloud",
|
||||
)
|
||||
assert kwargs["model"] == "deepseek-v4-pro:cloud"
|
||||
assert kwargs["reasoning_effort"] == "max"
|
||||
# No extra_body — Ollama Cloud uses top-level reasoning_effort
|
||||
assert "extra_body" not in kwargs or "reasoning" not in kwargs.get("extra_body", {})
|
||||
|
||||
def test_full_kwargs_with_disabled(self, ollama_cloud_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="deepseek-v4-pro:cloud",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=ollama_cloud_profile,
|
||||
reasoning_config={"enabled": False},
|
||||
base_url="https://ollama.com/v1",
|
||||
provider_name="ollama-cloud",
|
||||
)
|
||||
assert "reasoning_effort" not in kwargs
|
||||
|
||||
|
||||
class TestOllamaCloudAuxModel:
|
||||
"""Ollama Cloud aux model is set on the profile."""
|
||||
|
||||
def test_profile_advertises_aux_model(self, ollama_cloud_profile):
|
||||
assert ollama_cloud_profile.default_aux_model == "nemotron-3-nano:30b"
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Unit tests for OpenCode Go reasoning-control wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def opencode_go_profile():
|
||||
"""Resolve the registered OpenCode Go provider profile."""
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("opencode-go")
|
||||
assert profile is not None, "opencode-go provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestOpenCodeGoKimiReasoning:
|
||||
"""Kimi K2 models use Moonshot's thinking + reasoning_effort shape on OpenCode Go."""
|
||||
|
||||
def test_high_effort_emits_thinking_and_effort(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model="kimi-k2.6",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
def test_disabled_emits_thinking_disabled_without_effort(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False},
|
||||
model="kimi-k2.6",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_minimal_effort_enables_thinking_without_effort(self, opencode_go_profile):
|
||||
# "minimal" is not a Moonshot-supported value — drop it, keep thinking on.
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "minimal"},
|
||||
model="kimi-k2.6",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"effort",
|
||||
[
|
||||
"xhigh",
|
||||
"max",
|
||||
],
|
||||
)
|
||||
def test_strong_efforts_clamp_to_high(self, opencode_go_profile, effort):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="moonshotai/kimi-k2.6",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
def test_low_and_medium_pass_through(self, opencode_go_profile):
|
||||
for effort in ("low", "medium"):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="kimi-k2.5",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": effort}
|
||||
|
||||
def test_no_config_preserves_server_default(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None,
|
||||
model="kimi-k2.6",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestOpenCodeGoDeepSeekThinking:
|
||||
"""DeepSeek V4 models use DeepSeek-style thinking controls on OpenCode Go."""
|
||||
|
||||
def test_high_effort_emits_thinking_and_effort(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
def test_disabled_emits_thinking_disabled_without_effort(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_no_config_emits_thinking_enabled_without_effort(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None,
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_minimal_effort_enables_thinking_without_effort(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "minimal"},
|
||||
model="deepseek-v4-pro",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_xhigh_and_max_normalize_to_max(self, opencode_go_profile):
|
||||
for effort in ("xhigh", "max"):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="deepseek/deepseek-v4-pro",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
|
||||
class TestOpenCodeGoGLM52Reasoning:
|
||||
"""GLM-5.2 uses its native high/max reasoning_effort knob on OpenCode Go."""
|
||||
|
||||
def test_high_maps_to_high(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
def test_low_and_medium_clamp_up_to_high(self, opencode_go_profile):
|
||||
for effort in ("low", "medium", "minimal"):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
def test_xhigh_and_max_map_to_max(self, opencode_go_profile):
|
||||
for effort in ("xhigh", "max"):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="z-ai/glm-5.2",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
def test_disabled_leaves_server_default(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
def test_no_config_leaves_server_default(self, opencode_go_profile):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None,
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize("model", ["glm-5-2", "glm-5p2"])
|
||||
def test_alias_spellings_recognized(self, opencode_go_profile, model):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "max"},
|
||||
model=model,
|
||||
)
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
|
||||
class TestOpenCodeGoModelGating:
|
||||
"""Other OpenCode Go models must not receive Kimi/DeepSeek/GLM controls."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"qwen3.6-plus",
|
||||
"minimax-m2.7",
|
||||
"deepseek-v3.1",
|
||||
"deepseek-chat",
|
||||
"",
|
||||
None,
|
||||
],
|
||||
)
|
||||
def test_non_target_models_emit_nothing(self, opencode_go_profile, model):
|
||||
extra_body, top_level = opencode_go_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model=model,
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestOpenCodeGoFullKwargsIntegration:
|
||||
"""End-to-end transport kwargs include the profile-provided controls."""
|
||||
|
||||
def test_kimi_reasoning_reaches_extra_body_and_top_level(self, opencode_go_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="kimi-k2.6",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=opencode_go_profile,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
base_url="https://opencode.ai/zen/go/v1",
|
||||
)
|
||||
assert "extra_body" not in kwargs
|
||||
assert kwargs["reasoning_effort"] == "high"
|
||||
|
||||
def test_deepseek_thinking_reaches_extra_body_and_top_level(
|
||||
self, opencode_go_profile
|
||||
):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="deepseek-v4-pro",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=opencode_go_profile,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
base_url="https://opencode.ai/zen/go/v1",
|
||||
)
|
||||
assert "extra_body" not in kwargs
|
||||
assert kwargs["reasoning_effort"] == "high"
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Unit tests for the Z.AI / GLM provider profile's reasoning wiring.
|
||||
|
||||
Z.AI's GLM-4.5-and-later chat models default to thinking-mode ON when the
|
||||
request omits ``thinking``. Before the profile emitted the parameter,
|
||||
``reasoning_config = {"enabled": False}`` was a silent no-op on the direct
|
||||
Z.AI route — users who turned thinking off kept burning thinking tokens on
|
||||
every turn (the desktop "thinking reverts to medium" report).
|
||||
|
||||
GLM-5.2 additionally exposes a native ``reasoning_effort`` knob with two
|
||||
enabled levels (high / max) on the OpenAI-compatible ``/api/paas/v4``
|
||||
endpoint; the Hermes effort scale is collapsed onto those.
|
||||
|
||||
These tests pin the profile's wire-shape contract so Z.AI requests stay
|
||||
correctly shaped without going live.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zai_profile():
|
||||
"""Resolve the registered Z.AI profile through the real discovery path."""
|
||||
# ``model_tools`` triggers plugin discovery on import, which is what
|
||||
# registers the Z.AI profile in the global provider registry.
|
||||
import model_tools # noqa: F401
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("zai")
|
||||
assert profile is not None, "zai provider profile must be registered"
|
||||
return profile
|
||||
|
||||
|
||||
class TestZaiThinkingWireShape:
|
||||
"""``build_api_kwargs_extras`` produces Z.AI's exact wire format."""
|
||||
|
||||
def test_no_preference_omits_thinking(self, zai_profile):
|
||||
"""No reasoning_config → omit ``thinking`` so the server default
|
||||
applies (matches prior behavior for users with no preference)."""
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None, model="glm-5"
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
def test_enabled_sends_enabled_marker(self, zai_profile):
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "medium"}, model="glm-5"
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_explicitly_disabled_sends_disabled_marker(self, zai_profile):
|
||||
"""``reasoning_config.enabled=False`` → ``thinking.type=disabled``.
|
||||
|
||||
The crucial bit is that the parameter is *sent* at all — GLM defaults
|
||||
to thinking-on when ``thinking`` is absent, so an unsent disable
|
||||
burns thinking tokens forever.
|
||||
"""
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False}, model="glm-5"
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_no_effort_levels_leak_to_top_level(self, zai_profile):
|
||||
"""Non-5.2 GLM models have no effort knob — never emit
|
||||
``reasoning_effort`` for them (GLM-5.2 is the exception, below)."""
|
||||
for effort in ("minimal", "low", "medium", "high", "xhigh"):
|
||||
for model in ("glm-5", "glm-5.1", "glm-4.6"):
|
||||
_, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort}, model=model
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestZaiGLM52ReasoningEffort:
|
||||
"""GLM-5.2's native ``reasoning_effort`` knob (two enabled levels)."""
|
||||
|
||||
def test_high_maps_to_high(self, zai_profile):
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "minimal"])
|
||||
def test_lower_efforts_clamp_up_to_high(self, zai_profile, effort):
|
||||
"""GLM-5.2's minimum thinking level is high — lower Hermes levels
|
||||
clamp onto it."""
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {"reasoning_effort": "high"}
|
||||
|
||||
@pytest.mark.parametrize("effort", ["xhigh", "max"])
|
||||
def test_strong_efforts_map_to_max(self, zai_profile, effort):
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": effort},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
def test_disabled_sends_no_effort(self, zai_profile):
|
||||
"""Disabled reasoning still sends the thinking-off marker but never
|
||||
an effort level."""
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False, "effort": "high"},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
def test_no_config_leaves_server_default(self, zai_profile):
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config=None,
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
def test_no_effort_sends_no_effort_level(self, zai_profile):
|
||||
"""Enabled but no effort preference → thinking marker only; the
|
||||
server picks its default effort."""
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True},
|
||||
model="glm-5.2",
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "enabled"}}
|
||||
assert top_level == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"z-ai/glm-5.2",
|
||||
"glm-5-2",
|
||||
"glm-5p2",
|
||||
"accounts/fireworks/models/glm-5p2",
|
||||
"zai-org-glm-5-2",
|
||||
],
|
||||
)
|
||||
def test_alias_spellings_recognized(self, zai_profile, model):
|
||||
_, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "max"},
|
||||
model=model,
|
||||
)
|
||||
assert top_level == {"reasoning_effort": "max"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
["glm-5.1", "glm-5", "glm-4.7", "glm-4-9b", "", None],
|
||||
)
|
||||
def test_non_glm_5_2_models_get_no_effort(self, zai_profile, model):
|
||||
_, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
model=model,
|
||||
)
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestZaiModelGating:
|
||||
"""GLM 4.5+ get thinking; earlier GLM models are left untouched."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"glm-4.5",
|
||||
"glm-4.5-air",
|
||||
"glm-4.5-flash",
|
||||
"glm-4.6",
|
||||
"glm-5",
|
||||
"glm-5.2",
|
||||
"GLM-5", # case-insensitive
|
||||
],
|
||||
)
|
||||
def test_thinking_capable_models_emit_thinking(self, zai_profile, model):
|
||||
extra_body, _ = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False}, model=model
|
||||
)
|
||||
assert extra_body == {"thinking": {"type": "disabled"}}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"glm-4-9b", # pre-4.5, no thinking param
|
||||
"glm-4",
|
||||
"glm-3-turbo",
|
||||
"", # bare/unknown
|
||||
None, # missing
|
||||
"charglm-3", # non-GLM-versioned id
|
||||
],
|
||||
)
|
||||
def test_non_thinking_models_emit_nothing(self, zai_profile, model):
|
||||
extra_body, top_level = zai_profile.build_api_kwargs_extras(
|
||||
reasoning_config={"enabled": False}, model=model
|
||||
)
|
||||
assert extra_body == {}
|
||||
assert top_level == {}
|
||||
|
||||
|
||||
class TestZaiFullKwargsIntegration:
|
||||
"""End-to-end: the transport's full kwargs carry the reasoning wiring."""
|
||||
|
||||
def test_disabled_reaches_the_wire(self, zai_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="glm-5",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=zai_profile,
|
||||
reasoning_config={"enabled": False},
|
||||
base_url="https://api.z.ai/api/paas/v4",
|
||||
provider_name="zai",
|
||||
)
|
||||
assert kwargs["extra_body"]["thinking"] == {"type": "disabled"}
|
||||
|
||||
def test_no_preference_keeps_wire_clean(self, zai_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="glm-5",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=zai_profile,
|
||||
reasoning_config=None,
|
||||
base_url="https://api.z.ai/api/paas/v4",
|
||||
provider_name="zai",
|
||||
)
|
||||
assert "thinking" not in kwargs.get("extra_body", {})
|
||||
|
||||
def test_glm_5_2_effort_reaches_top_level(self, zai_profile):
|
||||
from agent.transports.chat_completions import ChatCompletionsTransport
|
||||
|
||||
kwargs = ChatCompletionsTransport().build_kwargs(
|
||||
model="glm-5.2",
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
tools=None,
|
||||
provider_profile=zai_profile,
|
||||
reasoning_config={"enabled": True, "effort": "max"},
|
||||
base_url="https://api.z.ai/api/paas/v4",
|
||||
provider_name="zai",
|
||||
)
|
||||
assert kwargs["reasoning_effort"] == "max"
|
||||
assert kwargs["extra_body"]["thinking"] == {"type": "enabled"}
|
||||
@@ -0,0 +1,574 @@
|
||||
"""Tests for the Photon auth module (device login + dashboard API)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.photon import auth as photon_auth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake httpx — we don't want to hit the real Photon API in unit tests.
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int = 200,
|
||||
json_body: Any = None,
|
||||
headers: Dict[str, str] | None = None,
|
||||
text: str = "",
|
||||
) -> None:
|
||||
self.status_code = status
|
||||
self._json = json_body if json_body is not None else {}
|
||||
self.headers = headers or {}
|
||||
self.text = text
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._json
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
_PHOTON_ENV = (
|
||||
"PHOTON_PROJECT_ID",
|
||||
"PHOTON_PROJECT_SECRET",
|
||||
"PHOTON_DASHBOARD_PROJECT_ID",
|
||||
"PHOTON_SPECTRUM_HOST",
|
||||
"PHOTON_ALLOWED_USERS",
|
||||
"PHOTON_HOME_CHANNEL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
yield home
|
||||
# save_env_value() mutates os.environ directly, so scrub any leakage.
|
||||
for key in _PHOTON_ENV:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential storage
|
||||
|
||||
def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_photon_token("abc123def456")
|
||||
assert photon_auth.load_photon_token() == "abc123def456"
|
||||
|
||||
auth_json = json.loads((tmp_hermes_home / "auth.json").read_text())
|
||||
assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456"
|
||||
|
||||
|
||||
def test_store_project_credentials_round_trip(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Don't touch .env / os.environ here — exercise the auth.json path.
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-123",
|
||||
project_secret="secret-key",
|
||||
dashboard_project_id="dash-456",
|
||||
name="Hermes Agent",
|
||||
)
|
||||
for key in _PHOTON_ENV:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid == "sp-123"
|
||||
assert secret == "secret-key"
|
||||
# Post-unification the management id resolves to the Spectrum id, not the
|
||||
# stored dashboard id — so a pre-backfill diverged install (whose old
|
||||
# dashboard id was rewritten and now 404s) still reaches the live row.
|
||||
assert photon_auth.load_dashboard_project_id() == "sp-123"
|
||||
|
||||
|
||||
def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-789",
|
||||
project_secret="sek-ret",
|
||||
dashboard_project_id="dash-1",
|
||||
)
|
||||
env_text = (tmp_hermes_home / ".env").read_text()
|
||||
assert "PHOTON_PROJECT_ID=sp-789" in env_text
|
||||
assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text
|
||||
|
||||
|
||||
def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number="+15551234567",
|
||||
assigned_phone_number="+16282679185",
|
||||
user_id="user-uuid",
|
||||
dashboard_project_id="dash-uuid",
|
||||
)
|
||||
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
assert phone == "+15551234567"
|
||||
assert assigned == "+16282679185"
|
||||
|
||||
summary = photon_auth.credential_summary()
|
||||
assert summary["phone_number"] == "+15551234567"
|
||||
assert summary["assigned_phone_number"] == "+16282679185"
|
||||
|
||||
rendered: list[str] = []
|
||||
photon_auth.print_credential_summary(rendered.append)
|
||||
assert " my number : +15551234567" in rendered[0]
|
||||
assert " assigned number : +16282679185" in rendered[0]
|
||||
|
||||
|
||||
def test_load_user_numbers_falls_back_to_home_channel(
|
||||
tmp_hermes_home: Path,
|
||||
) -> None:
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
assert phone == "+15551234567"
|
||||
assert assigned is None
|
||||
|
||||
|
||||
def test_refresh_user_numbers_reads_existing_assignment(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
photon_auth.store_user_numbers(phone_number="+15551234567")
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
assert kwargs.get("headers", {}).get("Authorization") == (
|
||||
"Basic " + b64encode(b"sp:secret").decode("ascii")
|
||||
)
|
||||
assert url.endswith("/projects/sp/users/")
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "user-uuid",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}]}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
|
||||
assert phone == "+15551234567"
|
||||
assert assigned == "+16282679185"
|
||||
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
|
||||
|
||||
|
||||
def test_load_project_credentials_env_override(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="from-file", project_secret="secret-file",
|
||||
)
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env")
|
||||
sid, secret = photon_auth.load_project_credentials()
|
||||
assert sid == "from-env"
|
||||
assert secret == "secret-env"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device login flow
|
||||
|
||||
def test_request_device_code_uses_photon_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev-code-xyz",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": "https://app.photon.codes/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
|
||||
code = photon_auth.request_device_code()
|
||||
assert code.device_code == "dev-code-xyz"
|
||||
assert code.user_code == "ABCD-1234"
|
||||
assert "/api/auth/device/code" in captured["url"]
|
||||
# Hosted Photon allowlists registered device clients — an unregistered
|
||||
# client_id is rejected with 400 invalid_client. We use Photon's published
|
||||
# CLI device client and send the standard scope.
|
||||
assert captured["body"]["client_id"] == "photon-cli"
|
||||
assert captured["body"]["scope"] == "openid profile email"
|
||||
|
||||
|
||||
def _device_code() -> "photon_auth.DeviceCode":
|
||||
return photon_auth.DeviceCode(
|
||||
device_code="d", user_code="u",
|
||||
verification_uri="https://x", verification_uri_complete=None,
|
||||
expires_in=10, interval=0,
|
||||
)
|
||||
|
||||
|
||||
def test_poll_for_token_body_access_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={"access_token": "tok-body"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-body"
|
||||
|
||||
|
||||
def test_poll_for_token_session_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={"session": {"access_token": "tok-sess"}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-sess"
|
||||
|
||||
|
||||
def test_poll_for_token_header_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=200, json_body={}, headers={"set-auth-token": "tok-hdr"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-hdr"
|
||||
|
||||
|
||||
def test_poll_for_token_pending_then_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return _FakeResponse(status=400, json_body={"error": "authorization_pending"})
|
||||
return _FakeResponse(status=200, json_body={"access_token": "tok-eventual"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=5) == "tok-eventual"
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_poll_for_token_access_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(status=400, json_body={"error": "access_denied"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="access_denied"):
|
||||
photon_auth.poll_for_token(_device_code(), interval=0, timeout=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
|
||||
def test_list_projects_unwraps_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[{"id": "p1", "name": "Hermes Agent"}])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
projects = photon_auth.list_projects("tok")
|
||||
assert projects[0]["id"] == "p1"
|
||||
|
||||
|
||||
def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"data": [
|
||||
{"id": "p1", "name": "Other"},
|
||||
{"id": "p2", "name": "hermes agent"},
|
||||
]})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
proj = photon_auth.find_project_by_name("tok", "Hermes Agent")
|
||||
assert proj is not None and proj["id"] == "p2"
|
||||
|
||||
|
||||
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
captured["headers"] = kwargs.get("headers")
|
||||
return _FakeResponse(json_body={"success": True, "id": "new-proj"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
data = photon_auth.create_project("tok", name="Hermes Agent")
|
||||
assert data["id"] == "new-proj"
|
||||
# Spectrum is always provisioned at create-time; the field was dropped
|
||||
# from the API schema, so we must not send it.
|
||||
assert "spectrum" not in captured["body"]
|
||||
assert captured["body"]["name"] == "Hermes Agent"
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok"
|
||||
assert captured["url"].endswith("/api/projects")
|
||||
|
||||
|
||||
def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"success": True})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="project id"):
|
||||
photon_auth.create_project("tok")
|
||||
|
||||
|
||||
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
assert url.endswith("/regenerate-secret")
|
||||
return _FakeResponse(json_body={"success": True, "projectSecret": "rotated"})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
assert photon_auth.regenerate_project_secret("tok", "p") == "rotated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Users
|
||||
|
||||
def test_create_user_rejects_invalid_phone() -> None:
|
||||
with pytest.raises(ValueError, match="E.164"):
|
||||
photon_auth.create_user("proj", "secret", phone_number="not-a-number")
|
||||
|
||||
|
||||
def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
captured["url"] = url
|
||||
captured["body"] = kwargs.get("json")
|
||||
captured["headers"] = kwargs.get("headers")
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {
|
||||
"id": "user-uuid", "phoneNumber": "+15551234567",
|
||||
}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567")
|
||||
assert user["id"] == "user-uuid"
|
||||
assert captured["body"]["type"] == "shared"
|
||||
assert captured["body"]["phoneNumber"] == "+15551234567"
|
||||
assert captured["headers"]["Authorization"] == (
|
||||
"Basic " + b64encode(b"proj-id:secret").decode("ascii")
|
||||
)
|
||||
assert captured["url"].endswith("/projects/proj-id/users/")
|
||||
|
||||
|
||||
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
posted = {"n": 0}
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
|
||||
"id": "u1",
|
||||
"phoneNumber": "+1 (555) 123-4567",
|
||||
"assignedPhoneNumber": "+16282679185",
|
||||
}]}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
posted["n"] += 1
|
||||
return _FakeResponse(json_body={"success": True, "user": {}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
# Same number, different formatting — should match and NOT create.
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is False
|
||||
assert user["id"] == "u1"
|
||||
assert posted["n"] == 0
|
||||
# The reused user carries the assigned iMessage line ("TEXTS ON").
|
||||
assert photon_auth.user_assigned_line(user) == "+16282679185"
|
||||
|
||||
|
||||
def test_user_assigned_line() -> None:
|
||||
assert (
|
||||
photon_auth.user_assigned_line({"assignedPhoneNumber": "+16282679185"})
|
||||
== "+16282679185"
|
||||
)
|
||||
# Own number present but no assignment yet (e.g. freshly created user).
|
||||
assert photon_auth.user_assigned_line({"phoneNumber": "+15551234567"}) is None
|
||||
assert photon_auth.user_assigned_line({"assignedPhoneNumber": ""}) is None
|
||||
assert photon_auth.user_assigned_line({}) is None
|
||||
assert photon_auth.user_assigned_line(None) is None
|
||||
|
||||
|
||||
def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"users": []}})
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body={"succeed": True, "data": {"id": "u-new"}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
"proj", "secret", phone_number="+15551234567",
|
||||
)
|
||||
assert created is True
|
||||
assert user["id"] == "u-new"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lines (assigned number)
|
||||
|
||||
def test_get_imessage_line_returns_existing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[
|
||||
{"id": "l1", "platform": "imessage", "phoneNumber": "+15559999999", "status": "active"},
|
||||
])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
line = photon_auth.get_imessage_line("tok", "proj")
|
||||
assert line is not None and line["phoneNumber"] == "+15559999999"
|
||||
|
||||
|
||||
def test_get_imessage_line_provisions_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
added = {"n": 0}
|
||||
|
||||
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
||||
added["n"] += 1
|
||||
assert kwargs.get("json", {}).get("platform") == "imessage"
|
||||
return _FakeResponse(json_body={"success": True, "line": {
|
||||
"id": "l-new", "platform": "imessage", "phoneNumber": "+15558888888",
|
||||
}})
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
line = photon_auth.get_imessage_line("tok", "proj")
|
||||
assert added["n"] == 1
|
||||
assert line["phoneNumber"] == "+15558888888"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential summary (no secret leakage)
|
||||
|
||||
def test_credential_summary_no_secret_leak(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None)
|
||||
photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa")
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id="sp-uuid",
|
||||
project_secret="secret-bbbbbbbbbbb",
|
||||
dashboard_project_id="dash-uuid",
|
||||
)
|
||||
summary = photon_auth.credential_summary()
|
||||
blob = "\n".join(summary.values())
|
||||
assert "token-aaaa" not in blob
|
||||
assert "secret-bbbb" not in blob
|
||||
assert summary["device_token"].startswith("✓")
|
||||
assert summary["project_key"].startswith("✓")
|
||||
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
|
||||
assert summary["project_id"] == "sp-uuid"
|
||||
assert summary["phone_number"].startswith("✗ missing")
|
||||
assert summary["assigned_phone_number"].startswith("✗ missing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-token candidate extraction + dashboard validation.
|
||||
|
||||
def test_device_response_candidates_covers_known_shapes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{
|
||||
"access_token": "tok-snake",
|
||||
"accessToken": "tok-camel",
|
||||
"data": {"access_token": "tok-data"},
|
||||
},
|
||||
headers={"set-auth-token": "Bearer tok-header"},
|
||||
)
|
||||
by_source = {c.source: c.token for c in candidates}
|
||||
assert by_source["access_token"] == "tok-snake"
|
||||
assert by_source["accessToken"] == "tok-camel"
|
||||
assert by_source["data.access_token"] == "tok-data"
|
||||
# "Bearer " prefix is stripped from the header value.
|
||||
assert by_source["set-auth-token"] == "tok-header"
|
||||
|
||||
|
||||
def test_device_response_candidates_dedupes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{"access_token": "same", "accessToken": "same"},
|
||||
)
|
||||
assert [c.token for c in candidates] == ["same"]
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_unrecognized_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={}) # no "user" key
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_project_api_denial(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(status=403) # project API rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_login_device_flow_validates_before_persisting(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
# device/token approval
|
||||
return _FakeResponse(json_body={"access_token": "good-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(json_body=[]) # projects OK
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
token = photon_auth.login_device_flow(open_browser=False)
|
||||
assert token == "good-token"
|
||||
assert photon_auth.load_photon_token() == "good-token"
|
||||
|
||||
|
||||
def test_login_device_flow_raises_when_token_invalid(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
return _FakeResponse(json_body={"access_token": "bad-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(status=401) # session lookup rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.login_device_flow(open_browser=False)
|
||||
# A token that failed validation must never be persisted.
|
||||
assert photon_auth.load_photon_token() is None
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Inbound dispatch + dedup tests for PhotonAdapter.
|
||||
|
||||
These bypass the loopback HTTP stream — they call ``_dispatch_inbound`` /
|
||||
``_on_inbound_line`` / ``_is_duplicate`` directly, exercising the
|
||||
sidecar-event parsing without spawning the Node sidecar or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _dm_event(text: str, msg_id: str = "spc-msg-abc") -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_event("hello world"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "hello world"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.message_id == "spc-msg-abc"
|
||||
src = event.source
|
||||
assert src is not None
|
||||
assert src.platform == Platform("photon")
|
||||
assert src.chat_id == "+15551234567"
|
||||
assert src.chat_type == "dm"
|
||||
assert src.user_id == "+15551234567"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_group_type(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = {
|
||||
"messageId": "spc-msg-grp",
|
||||
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": "hi group"},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
await adapter._dispatch_inbound(event)
|
||||
assert captured[0].source.chat_type == "group"
|
||||
|
||||
|
||||
# A real 1x1 transparent PNG (passes base.py's _looks_like_image magic check).
|
||||
_PNG_1X1_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhf"
|
||||
"DwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def _attachment_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-att"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "attachment", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _voice_event(
|
||||
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "voice", **content},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_without_bytes_surfaces_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No inline ``data`` (over cap / failed sidecar read) -> text marker, no media."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = _attachment_event(
|
||||
{"name": "IMG_4127.HEIC", "mimeType": "image/heic", "size": 12345}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert "Photon attachment received" in ev.text
|
||||
assert "IMG_4127.HEIC" in ev.text
|
||||
assert ev.message_type == MessageType.PHOTO
|
||||
assert ev.media_urls == []
|
||||
assert ev.media_types == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_downloads_image(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Inline base64 image bytes are decoded, cached, and exposed as media."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = base64.b64decode(_PNG_1X1_B64)
|
||||
event = _attachment_event(
|
||||
{
|
||||
"name": "photo.png",
|
||||
"mimeType": "image/png",
|
||||
"size": len(raw),
|
||||
"data": _PNG_1X1_B64,
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.PHOTO
|
||||
assert ev.media_types == ["image/png"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(attachment)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_group_preserves_text_and_attachment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Spectrum group content from a mixed text+image iMessage must not drop text."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
raw = base64.b64decode(_PNG_1X1_B64)
|
||||
|
||||
event = _attachment_event(
|
||||
{},
|
||||
msg_id="spc-msg-mixed",
|
||||
)
|
||||
event["content"] = {
|
||||
"type": "group",
|
||||
"items": [
|
||||
{
|
||||
"id": "p:0/spc-msg-mixed",
|
||||
"content": {"type": "text", "text": "请分析这张图的重点"},
|
||||
},
|
||||
{
|
||||
"id": "p:1/spc-msg-mixed",
|
||||
"content": {
|
||||
"type": "attachment",
|
||||
"name": "photo.png",
|
||||
"mimeType": "image/png",
|
||||
"size": len(raw),
|
||||
"data": _PNG_1X1_B64,
|
||||
"encoding": "base64",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.text == "请分析这张图的重点"
|
||||
assert ev.message_type == MessageType.PHOTO
|
||||
assert ev.media_types == ["image/png"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_voice_downloads_audio(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Inbound Spectrum voice content is cached and routed to auto-STT."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = b"OggS" + b"\x00" * 32
|
||||
event = _voice_event(
|
||||
{
|
||||
"name": "note.ogg",
|
||||
"mimeType": "audio/ogg",
|
||||
"duration": 7,
|
||||
"size": len(raw),
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.VOICE
|
||||
assert ev.media_types == ["audio/ogg"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(voice)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_voice_without_bytes_surfaces_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Metadata-only voice still tells the agent a voice note arrived."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
event = _voice_event(
|
||||
{"name": "note.m4a", "mimeType": "audio/mp4", "duration": 12, "size": 12345}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert "Photon voice received" in ev.text
|
||||
assert "note.m4a" in ev.text
|
||||
assert "duration: 12s" in ev.text
|
||||
assert ev.message_type == MessageType.VOICE
|
||||
assert ev.media_urls == []
|
||||
assert ev.media_types == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_attachment_downloads_document(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Non-image attachments route through the document cache as DOCUMENT."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
raw = b"%PDF-1.4 hermes test document"
|
||||
event = _attachment_event(
|
||||
{
|
||||
"name": "report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"size": len(raw),
|
||||
"data": base64.b64encode(raw).decode("ascii"),
|
||||
"encoding": "base64",
|
||||
}
|
||||
)
|
||||
await adapter._dispatch_inbound(event)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.message_type == MessageType.DOCUMENT
|
||||
assert ev.media_types == ["application/pdf"]
|
||||
assert len(ev.media_urls) == 1
|
||||
cached = Path(ev.media_urls[0])
|
||||
try:
|
||||
assert cached.is_file()
|
||||
assert cached.read_bytes() == raw
|
||||
assert ev.text == "(attachment)"
|
||||
finally:
|
||||
cached.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_inbound_line_dispatches_and_dedups(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
line = json.dumps(_dm_event("ping", msg_id="dup-1"))
|
||||
await adapter._on_inbound_line(line)
|
||||
await adapter._on_inbound_line(line) # same messageId -> deduped
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "ping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_inbound_line_ignores_bad_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._on_inbound_line("{not json")
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter._is_duplicate("id-1") is False
|
||||
assert adapter._is_duplicate("id-1") is True
|
||||
assert adapter._is_duplicate("id-2") is False
|
||||
assert adapter._is_duplicate("id-1") is True # still dup
|
||||
|
||||
|
||||
def test_is_duplicate_hard_size_bound(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A burst of unique ids within the window must not grow the dedup map past
|
||||
# its bound — evict oldest (LRU), not only expired entries.
|
||||
import plugins.platforms.photon.adapter as ad
|
||||
|
||||
monkeypatch.setattr(ad, "_DEDUP_MAX_SIZE", 5)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
for i in range(100):
|
||||
adapter._is_duplicate(f"id-{i}")
|
||||
assert len(adapter._seen_messages) <= 5
|
||||
assert adapter._is_duplicate("id-99") is True # recent still deduped
|
||||
assert adapter._is_duplicate("id-0") is False # oldest evicted
|
||||
|
||||
|
||||
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# If no node binary on PATH the adapter should refuse to start.
|
||||
from plugins.platforms.photon import adapter as adapter_mod
|
||||
|
||||
monkeypatch.setattr(adapter_mod.shutil, "which", lambda _name: None)
|
||||
assert adapter_mod.check_requirements() is False
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Markdown handling tests for PhotonAdapter.
|
||||
|
||||
Markdown is on by default (the sidecar sends it via spectrum-ts'
|
||||
``markdown()`` builder and iMessage renders it); ``PHOTON_MARKDOWN=false``
|
||||
reverts to the stripped-plain-text path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_MD = "**bold** and `code`"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def test_format_message_passthrough_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == _MD
|
||||
|
||||
|
||||
def test_format_message_strips_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.format_message(_MD) == "bold and code"
|
||||
|
||||
|
||||
def test_supports_code_blocks_mirrors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is True
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
assert _make_adapter(monkeypatch).supports_code_blocks is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
path, body = calls[0]
|
||||
assert path == "/send"
|
||||
assert body["format"] == "markdown"
|
||||
assert body["text"] == _MD # passed through unstripped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_send_omits_format_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Old-sidecar compat: the key is absent, not "text", when disabled."""
|
||||
monkeypatch.setenv("PHOTON_MARKDOWN", "false")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send("+15551234567", _MD)
|
||||
|
||||
_, body = calls[0]
|
||||
assert "format" not in body
|
||||
assert body["text"] == "bold and code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_includes_markdown_format(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_MARKDOWN", raising=False)
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(cfg, "+15551234567", _MD)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert posted[0][1]["format"] == "markdown"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Group-chat mention-gating tests for PhotonAdapter.
|
||||
|
||||
Parity with the BlueBubbles iMessage channel: when ``require_mention`` is
|
||||
enabled, group messages are dropped unless they hit a wake-word pattern,
|
||||
and the leading wake word is stripped from the ones that pass. DMs are
|
||||
never gated.
|
||||
|
||||
These call ``_dispatch_inbound`` directly (no aiohttp / ports) and assert
|
||||
on what reaches ``handle_message``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra=extra or {})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _group_payload(text: str) -> dict:
|
||||
return {
|
||||
"messageId": f"grp-{abs(hash(text))}",
|
||||
"space": {"id": "group-guid-xyz", "type": "group", "phone": None},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _dm_payload(text: str) -> dict:
|
||||
return {
|
||||
"messageId": f"dm-{abs(hash(text))}",
|
||||
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {"type": "text", "text": text},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def test_require_mention_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
assert adapter.require_mention is False
|
||||
# Defaults compile to the two Hermes wake-word patterns.
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_dropped_without_mention(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("just chatting, no wake word"))
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_passes_and_strips_wake_word(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("Hermes what's the weather"))
|
||||
assert len(captured) == 1
|
||||
# Leading wake word stripped before dispatch.
|
||||
assert captured[0].text == "what's the weather"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_never_gated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch, extra={"require_mention": True})
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_dm_payload("no wake word here"))
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "no wake word here"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_off_passes_group_messages(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch) # require_mention defaults off
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_group_payload("plain group chatter"))
|
||||
assert len(captured) == 1
|
||||
assert captured[0].text == "plain group chatter"
|
||||
|
||||
|
||||
def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": [r"(?<![\w@])@?amos\b[,:\-]?"]},
|
||||
)
|
||||
assert adapter.require_mention is True
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("amos help me") is True
|
||||
assert adapter._message_matches_mention_patterns("hermes help me") is False
|
||||
|
||||
|
||||
def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
adapter = PhotonAdapter(cfg)
|
||||
assert adapter.require_mention is True
|
||||
assert len(adapter._mention_patterns) == 2
|
||||
assert adapter._message_matches_mention_patterns("hey bot") is True
|
||||
|
||||
|
||||
def test_invalid_pattern_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
extra={"require_mention": True, "mention_patterns": ["(unclosed", r"good\b"]},
|
||||
)
|
||||
# Bad regex dropped, good one kept.
|
||||
assert len(adapter._mention_patterns) == 1
|
||||
assert adapter._message_matches_mention_patterns("a good thing") is True
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Outbound-media tests for PhotonAdapter.
|
||||
|
||||
Photon ships outbound attachments via spectrum-ts' ``attachment()`` /
|
||||
``voice()`` content builders, reached through the Node sidecar's
|
||||
``/send-attachment`` endpoint. These tests stub ``_sidecar_call`` so we
|
||||
can assert the endpoint + body shape each ``send_*`` override produces
|
||||
without spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False)
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Replace ``_sidecar_call`` with a recorder that returns a fixed id."""
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def real_file(tmp_path) -> str:
|
||||
p = tmp_path / "photo.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe0fake-jpeg")
|
||||
return str(p)
|
||||
|
||||
|
||||
def _patch_safe_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make path validation a passthrough so tmp files outside the cache pass."""
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: p if os.path.exists(p) else None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_hits_attachment_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file(
|
||||
"any;-;+15551234567", real_file, caption="look"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-123"
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["spaceId"] == "any;-;+15551234567"
|
||||
assert body["path"] == real_file
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "look"
|
||||
assert body["mimeType"] == "image/jpeg" # inferred from .jpg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_marks_kind_voice(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
audio = tmp_path / "note.m4a"
|
||||
audio.write_bytes(b"fake-audio")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_voice("any;-;+1", str(audio))
|
||||
|
||||
assert result.success is True
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["kind"] == "voice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_passes_filename(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
doc = tmp_path / "report.pdf"
|
||||
doc.write_bytes(b"%PDF-1.4 fake")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send_document("any;-;+1", str(doc), file_name="Q3.pdf")
|
||||
|
||||
_, body = calls[0]
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["name"] == "Q3.pdf"
|
||||
assert body["mimeType"] == "application/pdf"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_passes_through(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
vid = tmp_path / "clip.mp4"
|
||||
vid.write_bytes(b"fake-mp4")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.send_video("any;+;groupguid", str(vid), caption="watch")
|
||||
|
||||
_, body = calls[0]
|
||||
assert body["kind"] == "attachment"
|
||||
assert body["caption"] == "watch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_url_caches_then_sends_attachment(
|
||||
monkeypatch: pytest.MonkeyPatch, real_file: str
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
async def _fake_cache(url: str, *a, **k) -> str:
|
||||
assert url == "https://example.com/cat.jpg"
|
||||
return real_file
|
||||
|
||||
import gateway.platforms.base as base_mod
|
||||
|
||||
monkeypatch.setattr(base_mod, "cache_image_from_url", _fake_cache)
|
||||
|
||||
result = await adapter.send_image(
|
||||
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
path, body = calls[0]
|
||||
assert path == "/send-attachment"
|
||||
assert body["path"] == real_file
|
||||
assert body["caption"] == "cat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_url_fetch_failure_falls_back_to_text(
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
async def _boom(url: str, *a, **k) -> str:
|
||||
raise RuntimeError("network down")
|
||||
|
||||
import gateway.platforms.base as base_mod
|
||||
|
||||
monkeypatch.setattr(base_mod, "cache_image_from_url", _boom)
|
||||
|
||||
result = await adapter.send_image(
|
||||
"any;-;+1", "https://example.com/cat.jpg", caption="cat"
|
||||
)
|
||||
|
||||
# Fallback path: base send_image() routes to send() → /send (text).
|
||||
assert result.success is True
|
||||
assert calls[0][0] == "/send"
|
||||
assert "https://example.com/cat.jpg" in calls[0][1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_attachment_rejects_unsafe_path(
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Default validation (no passthrough patch) should reject a nonexistent /
|
||||
# traversal path, returning a failed SendResult without calling the sidecar.
|
||||
monkeypatch.setattr(
|
||||
PhotonAdapter,
|
||||
"validate_media_delivery_path",
|
||||
staticmethod(lambda p: None),
|
||||
)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
result = await adapter.send_image_file("any;-;+1", "/etc/passwd")
|
||||
|
||||
assert result.success is False
|
||||
assert "unsafe" in (result.error or "")
|
||||
assert calls == [] # never reached the sidecar
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_text_then_attachments(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
_patch_safe_path(monkeypatch)
|
||||
img = tmp_path / "a.png"
|
||||
img.write_bytes(b"\x89PNG fake")
|
||||
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
|
||||
|
||||
posted: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json() -> Dict[str, Any]:
|
||||
return {"ok": True, "messageId": "m-9"}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: Dict[str, Any], headers=None):
|
||||
posted.append((url, json))
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
result = await photon_adapter._standalone_send(
|
||||
cfg,
|
||||
"any;-;+1",
|
||||
"hello",
|
||||
media_files=[(str(img), False)],
|
||||
)
|
||||
|
||||
assert result.get("success") is True
|
||||
# First call is the text /send, second is /send-attachment.
|
||||
assert posted[0][0].endswith("/send")
|
||||
assert posted[0][1]["text"] == "hello"
|
||||
assert posted[1][0].endswith("/send-attachment")
|
||||
assert posted[1][1]["path"] == str(img)
|
||||
assert posted[1][1]["kind"] == "attachment"
|
||||
assert posted[1][1]["mimeType"] == "image/png"
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Photon adapter resilience to transient Spectrum/Envoy upstream overflow.
|
||||
|
||||
Covers the three behaviors that let the adapter ride through a Photon
|
||||
"reset reason: overflow" event instead of degrading delivery and silently
|
||||
dying (issue #50185):
|
||||
|
||||
1. ``_is_retryable_error`` classifies the Envoy/sidecar overflow strings as
|
||||
retryable so ``_send_with_retry`` actually engages its backoff loop.
|
||||
2. ``send_typing`` is rate-gated per chat, and ``stop_typing`` resets the
|
||||
gate so the next turn's typing indicator fires immediately.
|
||||
3. ``_supervise_sidecar`` detects an unexpected sidecar exit and raises a
|
||||
``retryable=True`` fatal so the gateway reconnect watcher revives the
|
||||
platform — instead of returning silently and leaving ``_inbound_loop``
|
||||
spinning against a dead port.
|
||||
4. ``_monitor_sidecar_health`` promotes degraded upstream stream health
|
||||
reported by ``/healthz`` into the same retryable reconnect path.
|
||||
|
||||
No Node sidecar is spawned and no ports are bound.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
# -- Gap 1: retryable classification of overflow errors ---------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
"UNAVAILABLE: internal sidecar error",
|
||||
"upstream connect error or disconnect/reset before headers",
|
||||
"reset reason: overflow",
|
||||
# Case-insensitive: real strings arrive with mixed case.
|
||||
"Internal Sidecar Error",
|
||||
],
|
||||
)
|
||||
def test_overflow_strings_classified_retryable(error: str) -> None:
|
||||
assert PhotonAdapter._is_retryable_error(error) is True
|
||||
|
||||
|
||||
def test_unrelated_error_not_retryable() -> None:
|
||||
# A genuine permanent failure must NOT be retried.
|
||||
assert PhotonAdapter._is_retryable_error("400 bad request: invalid spaceId") is False
|
||||
assert PhotonAdapter._is_retryable_error(None) is False
|
||||
|
||||
|
||||
def test_base_network_patterns_still_match() -> None:
|
||||
# The override delegates to the base classifier first, so generic
|
||||
# network strings keep working.
|
||||
assert PhotonAdapter._is_retryable_error("ConnectError: connection refused") is True
|
||||
|
||||
|
||||
# -- Gap 2: typing-indicator cooldown ---------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_cooldown_suppresses_rapid_repeats(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls: list[Dict[str, Any]] = []
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
calls.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# First call fires; immediate repeats are suppressed by the cooldown.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_cooldown_is_per_chat(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls: list[str] = []
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
calls.append(payload["spaceId"])
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# Different chats have independent cooldowns.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.send_typing("chat-2")
|
||||
|
||||
assert calls == ["chat-1", "chat-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_resets_cooldown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
starts = 0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
nonlocal starts
|
||||
if payload.get("state") == "start":
|
||||
starts += 1
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
|
||||
# A start, then a stop (end of turn), then a start for the next turn must
|
||||
# fire immediately — the cooldown only suppresses rapid consecutive starts
|
||||
# without an intervening stop.
|
||||
await adapter.send_typing("chat-1")
|
||||
await adapter.stop_typing("chat-1")
|
||||
await adapter.send_typing("chat-1")
|
||||
|
||||
assert starts == 2
|
||||
|
||||
|
||||
# -- Gap 3: sidecar crash detection -----------------------------------------
|
||||
|
||||
class _EofStdout:
|
||||
"""A proc.stdout whose readline() reports immediate EOF (dead sidecar)."""
|
||||
|
||||
def readline(self) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
class _DeadProc:
|
||||
"""Minimal subprocess.Popen stand-in for a sidecar that has exited."""
|
||||
|
||||
def __init__(self, exit_code: int = 1) -> None:
|
||||
self.stdout = _EofStdout()
|
||||
self.stdin = None
|
||||
self._exit_code = exit_code
|
||||
|
||||
def poll(self) -> int:
|
||||
return self._exit_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_sidecar_exit_raises_retryable_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# Simulate a live session whose sidecar then dies underneath it.
|
||||
adapter._inbound_running = True
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=137)) # type: ignore[arg-type]
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
|
||||
# retryable=True routes the platform into the reconnect watcher rather
|
||||
# than crashing the whole gateway.
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert adapter._running is False
|
||||
assert notified == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_shutdown_does_not_raise_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# disconnect() sets _inbound_running = False before stopping the sidecar,
|
||||
# so the detection block must NOT fire on a clean shutdown.
|
||||
adapter._inbound_running = False
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._supervise_sidecar(_DeadProc(exit_code=0)) # type: ignore[arg-type]
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
assert notified == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degraded_stream_health_raises_retryable_fatal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter._inbound_running = True
|
||||
adapter._sidecar_health_interval = 0.0
|
||||
|
||||
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
||||
assert path == "/healthz"
|
||||
return {
|
||||
"ok": True,
|
||||
"stream": {
|
||||
"ok": False,
|
||||
"state": "degraded",
|
||||
"degradedForMs": 120000,
|
||||
"lastIssue": "[spectrum.stream] stream interrupted; reconnecting",
|
||||
},
|
||||
}
|
||||
|
||||
notified: list[bool] = []
|
||||
|
||||
async def _fake_notify() -> None:
|
||||
notified.append(True)
|
||||
adapter._inbound_running = False
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
||||
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
||||
|
||||
await adapter._monitor_sidecar_health()
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert notified == [True]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Reaction (tapback) tests for PhotonAdapter.
|
||||
|
||||
Outbound reactions go through the sidecar's ``/react`` / ``/unreact``
|
||||
endpoints; these tests stub ``_sidecar_call`` to assert endpoint + body
|
||||
shape. Inbound reaction events are fed straight to ``_dispatch_inbound``.
|
||||
Neither path spawns the Node sidecar or binds ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
_EYES = "\U0001f440"
|
||||
_THUMBS_UP = "\U0001f44d"
|
||||
_THUMBS_DOWN = "\U0001f44e"
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture_sidecar(adapter: PhotonAdapter) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
calls: List[Tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def _fake_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
return {"ok": True, "messageId": "msg-123", "reactionId": "react-1"}
|
||||
|
||||
adapter._sidecar_call = _fake_call # type: ignore[assignment]
|
||||
return calls
|
||||
|
||||
|
||||
def _capture_handled(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _message_event(adapter: PhotonAdapter) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="hi",
|
||||
message_type=MessageType.TEXT,
|
||||
source=adapter.build_source(
|
||||
chat_id="+15551234567",
|
||||
chat_name="+15551234567",
|
||||
chat_type="dm",
|
||||
user_id="+15551234567",
|
||||
user_name=None,
|
||||
),
|
||||
message_id="target-msg-1",
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _reaction_event(
|
||||
emoji: str = "❤️",
|
||||
target_id: str = "bot-msg-1",
|
||||
target_direction: Any = "outbound",
|
||||
space_type: str = "dm",
|
||||
target_text: Any = "the bot's earlier reply",
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": "reaction-evt-1",
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+15551234567", "type": space_type, "phone": "+15551234567"},
|
||||
"sender": {"id": "+15551234567"},
|
||||
"content": {
|
||||
"type": "reaction",
|
||||
"emoji": emoji,
|
||||
"targetMessageId": target_id,
|
||||
"targetDirection": target_direction,
|
||||
# The sidecar always emits this key (hydrated reaction target);
|
||||
# null when the reacted-to message carried no text.
|
||||
"targetText": target_text,
|
||||
},
|
||||
"timestamp": "2026-06-11T10:00:00.000Z",
|
||||
}
|
||||
|
||||
|
||||
# -- Outbound: /react and /unreact body shapes ------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_reaction_posts_react(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._add_reaction("+15551234567", "target-msg-1", _EYES)
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
(
|
||||
"/react",
|
||||
{
|
||||
"spaceId": "+15551234567",
|
||||
"messageId": "target-msg-1",
|
||||
"emoji": _EYES,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_reaction_posts_unreact(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
ok = await adapter._remove_reaction("+15551234567", "target-msg-1")
|
||||
|
||||
assert ok is True
|
||||
assert calls == [
|
||||
("/unreact", {"spaceId": "+15551234567", "messageId": "target-msg-1"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_failure_is_soft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _boom(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise RuntimeError("sidecar down")
|
||||
|
||||
adapter._sidecar_call = _boom # type: ignore[assignment]
|
||||
|
||||
assert await adapter._add_reaction("+1", "m", _EYES) is False
|
||||
assert await adapter._remove_reaction("+1", "m") is False
|
||||
|
||||
|
||||
# -- Lifecycle hooks ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hooks_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_REACTIONS", raising=False)
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
event = _message_event(adapter)
|
||||
await adapter.on_processing_start(event)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_start_adds_eyes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_start(_message_event(adapter))
|
||||
|
||||
assert len(calls) == 1
|
||||
path, body = calls[0]
|
||||
assert path == "/react"
|
||||
assert body["emoji"] == _EYES
|
||||
assert body["messageId"] == "target-msg-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_success_swaps_to_thumbs_up(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.SUCCESS
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact", "/react"]
|
||||
assert calls[1][1]["emoji"] == _THUMBS_UP
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_failure_swaps_to_thumbs_down(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.FAILURE
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact", "/react"]
|
||||
assert calls[1][1]["emoji"] == _THUMBS_DOWN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_cancelled_only_removes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PHOTON_REACTIONS", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
calls = _capture_sidecar(adapter)
|
||||
|
||||
await adapter.on_processing_complete(
|
||||
_message_event(adapter), ProcessingOutcome.CANCELLED
|
||||
)
|
||||
|
||||
assert [path for path, _ in calls] == ["/unreact"]
|
||||
|
||||
|
||||
# -- Inbound reaction routing ------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_bot_message_routed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(emoji="❤️"))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.text == "reaction:added:❤️"
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.source.chat_id == "+15551234567"
|
||||
# The tapback correlates to the bot message it reacted to, so the gateway
|
||||
# can inject `[Replying to your previous message: "..."]` for context.
|
||||
assert event.reply_to_message_id == "bot-msg-1"
|
||||
assert event.reply_to_text == "the bot's earlier reply"
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_without_target_text_correlates_id_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A tapback on an attachment-only bot message (no text) still correlates the
|
||||
id, but leaves reply_to_text unset — the gateway then skips the reply pointer
|
||||
(it injects only when both id and text are present)."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(target_text=None))
|
||||
|
||||
assert len(captured) == 1
|
||||
event = captured[0]
|
||||
assert event.reply_to_message_id == "bot-msg-1"
|
||||
assert event.reply_to_text is None
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_sent_ids_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No targetDirection from the provider — gate on our own sent-id cache."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
adapter._record_sent_message("bot-msg-1")
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_reaction_event(target_id="bot-msg-1", target_direction=None)
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_on_foreign_message_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_reaction_event(target_id="someone-elses-msg", target_direction=None)
|
||||
)
|
||||
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_reaction_bypasses_require_mention(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A tapback never carries a wake word — it must skip group gating."""
|
||||
monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true")
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture_handled(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_reaction_event(space_type="group"))
|
||||
|
||||
assert len(captured) == 1
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for `hermes photon setup`'s access auto-configuration.
|
||||
|
||||
`_autoconfigure_access` allowlists the operator and points the cron home
|
||||
channel at their DM, writing to the per-test ~/.hermes/.env (the hermetic
|
||||
HERMES_HOME fixture isolates this). It must fill only unset keys so a re-run
|
||||
never clobbers a hand-tuned allowlist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from plugins.platforms.photon.adapter import _env_enablement
|
||||
from plugins.platforms.photon import cli
|
||||
|
||||
|
||||
def test_autoconfigure_access_fills_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
|
||||
|
||||
cli._autoconfigure_access("+15551234567")
|
||||
|
||||
assert get_env_value("PHOTON_ALLOWED_USERS") == "+15551234567"
|
||||
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
|
||||
|
||||
|
||||
def test_autoconfigure_access_preserves_existing_allowlist(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("PHOTON_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL", raising=False)
|
||||
# A hand-tuned allowlist already in place must survive a setup re-run.
|
||||
save_env_value("PHOTON_ALLOWED_USERS", "+19998887777,+15551112222")
|
||||
|
||||
cli._autoconfigure_access("+15551234567")
|
||||
|
||||
assert get_env_value("PHOTON_ALLOWED_USERS") == "+19998887777,+15551112222"
|
||||
# The still-unset home channel is filled.
|
||||
assert get_env_value("PHOTON_HOME_CHANNEL") == "+15551234567"
|
||||
|
||||
|
||||
def test_env_enablement_seeds_home_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL_NAME", "Primary DM")
|
||||
|
||||
seed = _env_enablement()
|
||||
|
||||
assert seed is not None
|
||||
assert seed["home_channel"] == {
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Primary DM",
|
||||
}
|
||||
|
||||
|
||||
def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "project_123")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret_123")
|
||||
monkeypatch.setenv("PHOTON_HOME_CHANNEL", "+15551234567")
|
||||
monkeypatch.delenv("PHOTON_HOME_CHANNEL_NAME", raising=False)
|
||||
|
||||
seed = _env_enablement()
|
||||
|
||||
assert seed is not None
|
||||
assert seed["home_channel"] == {
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Home",
|
||||
}
|
||||
|
||||
|
||||
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
|
||||
# longer enables Spectrum or fetches a separate spectrumProjectId — it
|
||||
# reuses this id directly.
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"regenerate_project_secret",
|
||||
lambda token, dashboard_id: "secret_123",
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+15551234567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Start the gateway: hermes gateway start" in out
|
||||
assert "--platform photon" not in out
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Regression tests for the Photon sidecar stale-dependency self-heal.
|
||||
|
||||
A `hermes update` that bumps the spectrum-ts pin rewrites the sidecar's
|
||||
``package-lock.json`` but never reinstalls ``node_modules``, so the sidecar
|
||||
spawns against stale deps and dies on every reconnect. ``_sidecar_deps_stale``
|
||||
detects that skew (lockfile newer than npm's install marker) so
|
||||
``_start_sidecar`` can reinstall before spawning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import plugins.platforms.photon.adapter as photon_adapter
|
||||
|
||||
|
||||
def _seed(sidecar: Path, *, lock_mtime: float, marker_mtime: float | None) -> None:
|
||||
"""Create a fake sidecar dir with a lockfile and (optionally) npm's marker."""
|
||||
(sidecar / "node_modules").mkdir(parents=True)
|
||||
lock = sidecar / "package-lock.json"
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
os.utime(lock, (lock_mtime, lock_mtime))
|
||||
if marker_mtime is not None:
|
||||
marker = sidecar / "node_modules" / ".package-lock.json"
|
||||
marker.write_text("{}", encoding="utf-8")
|
||||
os.utime(marker, (marker_mtime, marker_mtime))
|
||||
|
||||
|
||||
def test_stale_when_lockfile_newer_than_marker(tmp_path, monkeypatch) -> None:
|
||||
"""The update-rewrites-lockfile-but-skips-install case must reinstall."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=2000.0, marker_mtime=1000.0)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is True
|
||||
|
||||
|
||||
def test_fresh_when_marker_newer_than_lockfile(tmp_path, monkeypatch) -> None:
|
||||
"""A normal install (marker at/after lockfile) must NOT trigger a reinstall."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=1000.0, marker_mtime=2000.0)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is False
|
||||
|
||||
|
||||
def test_not_stale_when_marker_missing(tmp_path, monkeypatch) -> None:
|
||||
"""No marker (first run / unreadable) must fail safe to False, never block start."""
|
||||
sidecar = tmp_path / "sidecar"
|
||||
_seed(sidecar, lock_mtime=2000.0, marker_mtime=None)
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", sidecar)
|
||||
assert photon_adapter._sidecar_deps_stale() is False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Sidecar lifecycle tests: orphan reaping and parent-death wiring.
|
||||
|
||||
A hard gateway exit used to leave the detached Node sidecar squatting the
|
||||
loopback port with a token the next gateway run doesn't know — every
|
||||
replacement spawn then died on EADDRINUSE. These tests cover the startup
|
||||
reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without
|
||||
spawning Node or binding ports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.photon import adapter as photon_adapter
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
class _ProbeClient:
|
||||
"""Fake httpx.AsyncClient whose /healthz probe behavior is injectable."""
|
||||
|
||||
connects = True
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_ProbeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
if not self.connects:
|
||||
raise photon_adapter.httpx.ConnectError("connection refused")
|
||||
|
||||
class _Resp:
|
||||
status_code = 401 # orphan with a different token
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]:
|
||||
kills: List[Tuple[int, int]] = []
|
||||
|
||||
def _fake_kill(pid: int, sig: int) -> None:
|
||||
kills.append((pid, sig))
|
||||
|
||||
monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill)
|
||||
return kills
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
class _Refused(_ProbeClient):
|
||||
connects = False
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_kills_verified_orphan(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
|
||||
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242])
|
||||
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True)
|
||||
# Dies promptly on SIGTERM — no escalation expected.
|
||||
monkeypatch.setattr(adapter, "_pid_alive", lambda pid: False)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == [(4242, photon_adapter.signal.SIGTERM)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_escalates_to_sigkill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
|
||||
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [4242])
|
||||
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: True)
|
||||
monkeypatch.setattr(adapter, "_pid_alive", lambda pid: True) # ignores TERM
|
||||
# No clock fakery (logging also calls time.time, which makes a fake clock
|
||||
# fragile) — this test rides out the real 3s SIGTERM grace window.
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert (4242, photon_adapter.signal.SIGTERM) in kills
|
||||
assert (4242, photon_adapter.signal.SIGKILL) in kills
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reap_raises_for_foreign_listener(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Never signal a process whose command line isn't our sidecar."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _ProbeClient)
|
||||
monkeypatch.setattr(adapter, "_find_listener_pids", lambda port: [777])
|
||||
monkeypatch.setattr(adapter, "_pid_is_sidecar", lambda pid: False)
|
||||
kills = _capture_kills(monkeypatch)
|
||||
|
||||
with pytest.raises(RuntimeError, match="in use by another process"):
|
||||
await adapter._reap_stale_sidecar()
|
||||
|
||||
assert kills == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sidecar_spawns_with_stdin_pipe(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""The spawn must hold a stdin pipe and enable the sidecar's EOF watch."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
|
||||
async def _no_reap() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
|
||||
|
||||
spawned: Dict[str, Any] = {}
|
||||
|
||||
class _FakeProc:
|
||||
pid = 999
|
||||
stdout = None
|
||||
stdin = None
|
||||
|
||||
@staticmethod
|
||||
def poll() -> None:
|
||||
return None
|
||||
|
||||
def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
|
||||
spawned["cmd"] = cmd
|
||||
spawned["kwargs"] = kwargs
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)
|
||||
|
||||
class _HealthyClient(_ProbeClient):
|
||||
async def post(self, *a: Any, **k: Any) -> Any:
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)
|
||||
|
||||
await adapter._start_sidecar()
|
||||
|
||||
kwargs = spawned["kwargs"]
|
||||
assert kwargs["stdin"] is subprocess.PIPE
|
||||
assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
|
||||
|
||||
|
||||
def test_sidecar_applies_spectrum_patch_before_importing_sdk() -> None:
|
||||
"""Existing installs should self-heal at runtime, not only during npm postinstall."""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "import { patchSpectrumTs }" in index
|
||||
assert "patchSpectrumTs();" in index
|
||||
assert index.index("patchSpectrumTs();") < index.index('await import("spectrum-ts")')
|
||||
|
||||
|
||||
def test_sidecar_healthz_reports_stream_health() -> None:
|
||||
"""Local process health must include upstream stream health."""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "function streamHealthSnapshot()" in index
|
||||
assert 'return ok(res, { stream: streamHealthSnapshot() });' in index
|
||||
assert "STREAM_INTERRUPTED_DEGRADE_COUNT" in index
|
||||
assert "process.exit(75);" in index
|
||||
|
||||
|
||||
def test_sidecar_intercepts_both_console_channels() -> None:
|
||||
"""spectrum-ts routes its stream telemetry through @photon-ai/otel, which
|
||||
sends severity >= ERROR to console.error and WARN/INFO to console.log.
|
||||
The two lines the health monitor keys off land on *different* channels:
|
||||
`log.error("stream persistently failing")` -> console.error, but
|
||||
`log.warn("stream interrupted; reconnecting")` -> console.log. Patching
|
||||
only console.error would miss every interrupt burst (the primary silent-
|
||||
inbound symptom), so both channels must be intercepted.
|
||||
"""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "function classifyStreamLog(" in index
|
||||
assert "console.error = (...args) =>" in index
|
||||
assert "console.log = (...args) =>" in index
|
||||
# Both wrappers must feed the shared classifier.
|
||||
assert index.count("classifyStreamLog(text)") >= 2
|
||||
|
||||
|
||||
def test_sidecar_labels_catchup_internal_errors_as_upstream_photon() -> None:
|
||||
"""Photon cloud stream failures should not look like local auth problems."""
|
||||
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
||||
assert "function inboundStreamErrorMessage" in index
|
||||
assert "EventService/CatchUpEvents" in index
|
||||
assert "this is upstream of Hermes" in index
|
||||
assert "PHOTON_ALLOWED_USERS" in index
|
||||
|
||||
|
||||
def _tabify(src: str) -> str:
|
||||
"""Convert the fixture's two-space indentation to the tab indentation that
|
||||
spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors
|
||||
(which match tabs) apply exactly as they do against a real install."""
|
||||
out = []
|
||||
for line in src.split("\n"):
|
||||
stripped = line.lstrip(" ")
|
||||
indent = len(line) - len(stripped)
|
||||
out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper:
|
||||
# the two functions the patch rewrites (`rebuildFromAppleMessage` for
|
||||
# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of
|
||||
# the helpers they close over. Mirrors the published shape — tab-indented (via
|
||||
# `_tabify`), `const ... = async` declarations, single-line builder calls — so
|
||||
# the anchors exercise the real code path, and exporting the two functions lets
|
||||
# the test assert runtime behavior rather than only string shape.
|
||||
_SPECTRUM_IMESSAGE_FIXTURE = """
|
||||
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
|
||||
const asText = (text) => ({ type: "text", text });
|
||||
const asCustom = (message) => ({ type: "custom" });
|
||||
const asProviderGroup = (items) => ({ type: "group", items });
|
||||
const messageAttachments = (message) => message.content.attachments ?? [];
|
||||
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp });
|
||||
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
|
||||
const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex };
|
||||
if (parentId !== void 0) msg.parentId = parentId;
|
||||
return msg;
|
||||
};
|
||||
const cacheMessage = (cache, message) => { cache.set(message.id, message); };
|
||||
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
|
||||
const messageGuidStr = message.guid;
|
||||
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length === 1) {
|
||||
const info = attachments[0];
|
||||
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
||||
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
||||
}
|
||||
if (attachments.length > 1) {
|
||||
const items = [];
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const info = attachments[i];
|
||||
if (!info) continue;
|
||||
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: asProviderGroup(items)
|
||||
};
|
||||
}
|
||||
const text = message.content.text;
|
||||
return {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: text ? asText(text) : asCustom(message)
|
||||
};
|
||||
};
|
||||
const toInboundMessages = async (client, cache, event, phone) => {
|
||||
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
|
||||
const messageGuidStr = event.message.guid;
|
||||
const attachments = messageAttachments(event.message);
|
||||
if (attachments.length === 1) {
|
||||
const info = attachments[0];
|
||||
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
||||
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
||||
cacheMessage(cache, msg);
|
||||
return [msg];
|
||||
}
|
||||
if (attachments.length > 1) {
|
||||
const items = [];
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const info = attachments[i];
|
||||
if (!info) continue;
|
||||
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
||||
}
|
||||
const parent = {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: asProviderGroup(items)
|
||||
};
|
||||
cacheMessage(cache, parent);
|
||||
return [parent];
|
||||
}
|
||||
const text = event.message.content.text;
|
||||
const msg = {
|
||||
...base,
|
||||
id: messageGuidStr,
|
||||
content: text ? asText(text) : asCustom(event.message)
|
||||
};
|
||||
cacheMessage(cache, msg);
|
||||
return [msg];
|
||||
};
|
||||
export { rebuildFromAppleMessage, toInboundMessages };
|
||||
"""
|
||||
|
||||
|
||||
def _write_fixture(tmp_path: Path) -> Path:
|
||||
dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist"
|
||||
dist.mkdir(parents=True)
|
||||
chunk = dist / "index.js"
|
||||
chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8")
|
||||
return chunk
|
||||
|
||||
|
||||
def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None:
|
||||
"""The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk
|
||||
and rewrite both inbound mappers to thread text through attachment bubbles."""
|
||||
chunk = _write_fixture(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
patched = chunk.read_text(encoding="utf-8")
|
||||
assert "Preserve mixed text + attachment iMessage payloads" in patched
|
||||
# Single-attachment bubbles wrap the text + attachment in a group...
|
||||
assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild
|
||||
assert "content: asProviderGroup([textMsg, msg])" in patched # inbound
|
||||
# ...multi-attachment bubbles keep the group and shift attachment indices.
|
||||
assert "content: asProviderGroup(items)" in patched
|
||||
assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched
|
||||
# The text is captured in both mappers before the attachment branches run.
|
||||
assert "const text2 = message.content.text;" in patched
|
||||
assert "const text2 = event.message.content.text;" in patched
|
||||
|
||||
# Re-running is a no-op (idempotent self-heal on every sidecar start).
|
||||
again = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert again.returncode == 0, again.stderr
|
||||
assert chunk.read_text(encoding="utf-8") == patched
|
||||
|
||||
|
||||
def test_spectrum_patch_preserves_text_at_runtime(tmp_path: Path) -> None:
|
||||
"""Execute the patched mappers and assert mixed bubbles become groups whose
|
||||
first child is the typed text, while text-free bubbles keep their exact
|
||||
original shape (id/partIndex/parentId) so message identity is unchanged."""
|
||||
chunk = _write_fixture(tmp_path)
|
||||
patch = subprocess.run(
|
||||
["node", str(_PATCHER), str(tmp_path)],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert patch.returncode == 0, patch.stderr
|
||||
|
||||
harness = textwrap.dedent(
|
||||
f"""
|
||||
import {{ rebuildFromAppleMessage, toInboundMessages }} from {str(chunk)!r};
|
||||
const assert = (c, m) => {{ if (!c) {{ console.error("FAIL: " + m); process.exit(1); }} }};
|
||||
|
||||
// Mixed text + single attachment -> group [text@0, attachment@1].
|
||||
let r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "hello", attachments: [{{ guid: "A0" }}] }} }}, "+1");
|
||||
assert(r.content.type === "group" && r.id === "G", "single+text -> group parent id=guid");
|
||||
assert(r.content.items.length === 2, "two items");
|
||||
assert(r.content.items[0].content.type === "text" && r.content.items[0].content.text === "hello" && r.content.items[0].partIndex === 0 && r.content.items[0].id === "p:0/G", "text child @0");
|
||||
assert(r.content.items[1].content.type === "attachment" && r.content.items[1].partIndex === 1 && r.content.items[1].id === "p:1/G" && r.content.items[1].parentId === "G", "attachment child @1");
|
||||
|
||||
// Single attachment, no text -> unchanged bare attachment.
|
||||
r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "", attachments: [{{ guid: "A0" }}] }} }}, "+1");
|
||||
assert(r.content.type === "attachment" && r.id === "G" && r.partIndex === 0 && r.parentId === undefined, "no-text single attachment unchanged");
|
||||
|
||||
// Multi attachment + text via the live stream -> group [text@0, att@1, att@2].
|
||||
let arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G2", content: {{ text: "cap", attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
|
||||
assert(arr.length === 1 && arr[0].content.type === "group", "multi+text -> single group");
|
||||
let items = arr[0].content.items;
|
||||
assert(items.length === 3 && items[0].content.type === "text" && items[0].partIndex === 0, "text first @0");
|
||||
assert(items[1].partIndex === 1 && items[1].id === "p:1/G2" && items[2].partIndex === 2 && items[2].id === "p:2/G2", "attachments shifted to @1,@2");
|
||||
|
||||
// Multi attachment, no text -> unchanged (attachments at @0,@1).
|
||||
arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G3", content: {{ attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
|
||||
items = arr[0].content.items;
|
||||
assert(items.length === 2 && items[0].partIndex === 0 && items[0].id === "p:0/G3" && items[1].partIndex === 1, "no-text multi unchanged");
|
||||
|
||||
// Text only, no attachments -> plain text (unchanged).
|
||||
r = await rebuildFromAppleMessage(null, {{ guid: "G4", content: {{ text: "just text", attachments: [] }} }}, "+1");
|
||||
assert(r.content.type === "text" && r.content.text === "just text" && r.id === "G4", "text-only unchanged");
|
||||
"""
|
||||
)
|
||||
run = subprocess.run(
|
||||
["node", "--input-type=module", "-e", harness],
|
||||
cwd=Path.cwd(),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Tests for the bundled hermes-achievements dashboard plugin.
|
||||
|
||||
These target the two behaviors that matter for official integration:
|
||||
|
||||
* The 200-session scan cap is removed — the plugin now walks the entire
|
||||
session history by default. Lifetime badges (tens of thousands of
|
||||
tool calls) were unreachable before this fix on long-running installs.
|
||||
* First-ever scans run in a background thread so the dashboard request
|
||||
path never blocks, even on 8000+ session databases where a cold scan
|
||||
takes minutes.
|
||||
|
||||
The upstream repo ships its own unittest suite under
|
||||
``plugins/hermes-achievements/tests/`` covering the achievement engine
|
||||
internals (tier math, secret-state handling, catalog invariants). These
|
||||
tests live at the hermes-agent level and focus on the integration
|
||||
contract: the plugin scans ALL of your sessions, not the first 200.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
PLUGIN_MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "plugins"
|
||||
/ "hermes-achievements"
|
||||
/ "dashboard"
|
||||
/ "plugin_api.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_api(tmp_path, monkeypatch):
|
||||
"""Load plugin_api with isolated ~/.hermes so state/snapshot files don't collide.
|
||||
|
||||
We load the module fresh per test because the plugin keeps module-level
|
||||
caches (``_SNAPSHOT_CACHE``, ``_SCAN_STATUS``, background thread handle).
|
||||
Reloading gives each test a clean world.
|
||||
"""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"plugin_api_test_{id(tmp_path)}", PLUGIN_MODULE_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
# Stash monkeypatch so ``_install_fake_session_db`` can use it to
|
||||
# swap ``sys.modules['hermes_state']`` with auto-restoration. Without
|
||||
# this, a raw ``sys.modules[...] = fake`` assignment would leak the
|
||||
# fake into later tests in the same xdist worker — breaking every
|
||||
# test that does ``from hermes_state import SessionDB``.
|
||||
module._test_monkeypatch = monkeypatch
|
||||
yield module
|
||||
|
||||
|
||||
class _FakeSessionDB:
|
||||
"""Stand-in for hermes_state.SessionDB that records scan calls."""
|
||||
|
||||
def __init__(self, session_count: int, scan_delay: float = 0):
|
||||
self.session_count = session_count
|
||||
self.scan_delay = scan_delay
|
||||
self.last_limit: Optional[int] = None
|
||||
self.last_include_children: Optional[bool] = None
|
||||
self.list_calls = 0
|
||||
self.messages_calls = 0
|
||||
|
||||
def list_sessions_rich(
|
||||
self,
|
||||
source: Optional[str] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
project_compression_tips: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
if self.scan_delay:
|
||||
time.sleep(self.scan_delay)
|
||||
self.last_limit = limit
|
||||
self.last_include_children = include_children
|
||||
self.list_calls += 1
|
||||
# SQLite semantics: LIMIT -1 = unlimited. Honor that here.
|
||||
effective = self.session_count if limit == -1 else min(self.session_count, limit)
|
||||
now = int(time.time())
|
||||
return [
|
||||
{
|
||||
"id": f"sess-{i}",
|
||||
"title": f"Session {i}",
|
||||
"preview": f"preview {i}",
|
||||
"started_at": now - (self.session_count - i) * 60,
|
||||
"last_active": now - (self.session_count - i) * 60 + 30,
|
||||
"source": "cli",
|
||||
"model": "test-model",
|
||||
}
|
||||
for i in range(effective)
|
||||
]
|
||||
|
||||
def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
self.messages_calls += 1
|
||||
return [
|
||||
{"role": "user", "content": f"ask {session_id}"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"function": {"name": "terminal"}}],
|
||||
},
|
||||
{"role": "tool", "tool_name": "terminal", "content": "ok"},
|
||||
]
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _install_fake_session_db(plugin_api, fake_db):
|
||||
"""Inject a fake SessionDB so ``scan_sessions`` finds it via its local import.
|
||||
|
||||
Uses the monkeypatch stashed on ``plugin_api`` by the fixture, so the
|
||||
``sys.modules['hermes_state']`` swap is auto-restored at test teardown
|
||||
and cannot leak into unrelated tests in the same xdist worker.
|
||||
"""
|
||||
fake_module = type(sys)("hermes_state")
|
||||
fake_module.SessionDB = lambda: fake_db
|
||||
plugin_api._test_monkeypatch.setitem(sys.modules, "hermes_state", fake_module)
|
||||
|
||||
|
||||
def test_scan_sessions_default_scans_all_history_not_first_200(plugin_api):
|
||||
"""Bug regression: ``scan_sessions()`` used to cap at limit=200.
|
||||
|
||||
A user with 8000+ sessions would only see ~2% of their history in
|
||||
achievement totals, making lifetime badges unreachable. The default
|
||||
now passes ``LIMIT -1`` (SQLite "unlimited") to ``list_sessions_rich``.
|
||||
"""
|
||||
fake_db = _FakeSessionDB(session_count=500) # > old 200 cap
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
result = plugin_api.scan_sessions()
|
||||
|
||||
assert fake_db.last_limit == -1, (
|
||||
"scan_sessions() must pass LIMIT=-1 (unlimited) to list_sessions_rich "
|
||||
f"by default, got {fake_db.last_limit}"
|
||||
)
|
||||
assert fake_db.last_include_children is True, (
|
||||
"scan_sessions() must include subagent/compression child sessions so "
|
||||
"tool calls made in delegated agents still count toward achievements"
|
||||
)
|
||||
assert len(result["sessions"]) == 500
|
||||
assert result["scan_meta"]["sessions_total"] == 500
|
||||
|
||||
|
||||
def test_scan_sessions_explicit_positive_limit_is_honored(plugin_api):
|
||||
"""Callers can still pass a small limit for smoke tests."""
|
||||
fake_db = _FakeSessionDB(session_count=500)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
result = plugin_api.scan_sessions(limit=10)
|
||||
|
||||
assert fake_db.last_limit == 10
|
||||
assert len(result["sessions"]) == 10
|
||||
|
||||
|
||||
def test_scan_sessions_zero_or_negative_limit_means_unlimited(plugin_api):
|
||||
"""``limit=0`` and ``limit=-1`` both map to the unlimited path."""
|
||||
fake_db = _FakeSessionDB(session_count=300)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
plugin_api.scan_sessions(limit=0)
|
||||
assert fake_db.last_limit == -1
|
||||
|
||||
plugin_api.scan_sessions(limit=-1)
|
||||
assert fake_db.last_limit == -1
|
||||
|
||||
|
||||
def test_evaluate_all_first_run_returns_pending_and_starts_background_scan(plugin_api):
|
||||
"""First-ever evaluate_all with no cache returns a pending placeholder
|
||||
immediately and kicks off a background scan thread. Cold scans on
|
||||
large DBs take minutes — blocking the dashboard request path is not
|
||||
acceptable.
|
||||
"""
|
||||
fake_db = _FakeSessionDB(session_count=50)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
# Wrap _run_scan_and_update_cache so we can release it on demand,
|
||||
# simulating a slow cold scan without actually waiting.
|
||||
scan_started = threading.Event()
|
||||
allow_scan_finish = threading.Event()
|
||||
original_run = plugin_api._run_scan_and_update_cache
|
||||
|
||||
def gated_run(*args, **kwargs):
|
||||
scan_started.set()
|
||||
allow_scan_finish.wait(timeout=5)
|
||||
original_run(*args, **kwargs)
|
||||
|
||||
plugin_api._run_scan_and_update_cache = gated_run
|
||||
|
||||
t0 = time.time()
|
||||
result = plugin_api.evaluate_all()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Immediate return — should not block waiting for the scan.
|
||||
assert elapsed < 1.0, f"evaluate_all blocked for {elapsed:.2f}s on first run"
|
||||
assert result["scan_meta"]["mode"] == "pending"
|
||||
assert result["unlocked_count"] == 0
|
||||
# Catalog still rendered so UI has something to draw.
|
||||
assert result["total_count"] >= 60
|
||||
|
||||
# Background scan is running.
|
||||
assert scan_started.wait(timeout=2), "background scan did not start"
|
||||
|
||||
# Let the scan complete, then a second call returns real data.
|
||||
allow_scan_finish.set()
|
||||
# Wait for thread to finish.
|
||||
thread = plugin_api._BACKGROUND_SCAN_THREAD
|
||||
assert thread is not None
|
||||
thread.join(timeout=5)
|
||||
assert not thread.is_alive()
|
||||
|
||||
second = plugin_api.evaluate_all()
|
||||
assert second["scan_meta"]["mode"] != "pending"
|
||||
assert second["scan_meta"].get("sessions_total") == 50
|
||||
|
||||
|
||||
def test_evaluate_all_stale_cache_serves_stale_and_refreshes_in_background(plugin_api):
|
||||
"""When the snapshot is on-disk but older than TTL, evaluate_all returns
|
||||
the stale data immediately and kicks a background refresh. Users don't
|
||||
stare at a loading spinner every time TTL expires.
|
||||
"""
|
||||
fake_db = _FakeSessionDB(session_count=10, scan_delay=2.0)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
stale_generated_at = int(time.time()) - plugin_api.SNAPSHOT_TTL_SECONDS - 60
|
||||
stale_payload = {
|
||||
"achievements": [],
|
||||
"sessions": [],
|
||||
"aggregate": {},
|
||||
"scan_meta": {"mode": "full", "sessions_total": 1, "sessions_rescanned": 1, "sessions_reused": 0},
|
||||
"error": None,
|
||||
"unlocked_count": 0,
|
||||
"discovered_count": 0,
|
||||
"secret_count": 0,
|
||||
"total_count": 0,
|
||||
"generated_at": stale_generated_at,
|
||||
}
|
||||
plugin_api.save_snapshot(stale_payload)
|
||||
|
||||
t0 = time.time()
|
||||
result = plugin_api.evaluate_all()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
assert elapsed < 1.0, f"evaluate_all blocked for {elapsed:.2f}s serving stale data"
|
||||
assert result["generated_at"] == stale_generated_at
|
||||
|
||||
# Background scan should be running or have completed.
|
||||
thread = plugin_api._BACKGROUND_SCAN_THREAD
|
||||
assert thread is not None
|
||||
thread.join(timeout=5)
|
||||
|
||||
fresh = plugin_api.evaluate_all()
|
||||
assert fresh["generated_at"] >= stale_generated_at
|
||||
|
||||
|
||||
def test_evaluate_all_force_runs_synchronously(plugin_api):
|
||||
"""Manual /rescan (force=True) blocks the caller — users clicking
|
||||
the rescan button expect up-to-date data when the call returns.
|
||||
"""
|
||||
fake_db = _FakeSessionDB(session_count=25)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
result = plugin_api.evaluate_all(force=True)
|
||||
|
||||
# Synchronous — snapshot is fresh on return.
|
||||
assert result["scan_meta"].get("sessions_total") == 25
|
||||
assert result["scan_meta"]["mode"] in {"full", "incremental"}
|
||||
|
||||
|
||||
def test_start_background_scan_is_idempotent_while_running(plugin_api):
|
||||
"""Multiple concurrent dashboard requests must not spawn duplicate scans."""
|
||||
fake_db = _FakeSessionDB(session_count=5)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
release = threading.Event()
|
||||
original_run = plugin_api._run_scan_and_update_cache
|
||||
|
||||
def gated_run(*args, **kwargs):
|
||||
release.wait(timeout=5)
|
||||
original_run(*args, **kwargs)
|
||||
|
||||
plugin_api._run_scan_and_update_cache = gated_run
|
||||
|
||||
plugin_api._start_background_scan()
|
||||
first_thread = plugin_api._BACKGROUND_SCAN_THREAD
|
||||
assert first_thread is not None and first_thread.is_alive()
|
||||
|
||||
plugin_api._start_background_scan()
|
||||
plugin_api._start_background_scan()
|
||||
|
||||
assert plugin_api._BACKGROUND_SCAN_THREAD is first_thread
|
||||
|
||||
release.set()
|
||||
first_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_background_scan_publishes_partial_snapshots(plugin_api):
|
||||
"""The background scanner publishes intermediate snapshots to the cache
|
||||
every ~N sessions. Each dashboard refresh during a long cold scan sees
|
||||
more badges unlocked instead of staring at zeros for minutes and then
|
||||
having everything pop at the end.
|
||||
"""
|
||||
fake_db = _FakeSessionDB(session_count=750)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
# Record every partial snapshot the scanner publishes.
|
||||
partial_snapshots: List[Dict[str, Any]] = []
|
||||
original_compute_from_scan = plugin_api._compute_from_scan
|
||||
|
||||
def recording_compute(scan, *, is_partial=False):
|
||||
result = original_compute_from_scan(scan, is_partial=is_partial)
|
||||
if is_partial:
|
||||
partial_snapshots.append(result)
|
||||
return result
|
||||
|
||||
plugin_api._compute_from_scan = recording_compute
|
||||
|
||||
# scan 750 sessions with progress_every=250 → expect 2 intermediate
|
||||
# publications (at 250 and 500; the final 750 call goes through the
|
||||
# finished, non-partial path).
|
||||
plugin_api._run_scan_and_update_cache(publish_partial_snapshots=True)
|
||||
|
||||
assert len(partial_snapshots) >= 2, (
|
||||
f"expected at least 2 partial publications on a 750-session scan with "
|
||||
f"progress_every=250, got {len(partial_snapshots)}"
|
||||
)
|
||||
# Partial snapshots should report growing session counts.
|
||||
counts = [p["scan_meta"].get("sessions_scanned_so_far") for p in partial_snapshots]
|
||||
assert counts == sorted(counts), f"partial session counts not monotonic: {counts}"
|
||||
assert counts[0] < 750 and counts[-1] < 750, (
|
||||
f"partial counts should be less than the final total; got {counts}"
|
||||
)
|
||||
# Every partial reports the expected end-state total so the UI can
|
||||
# show an accurate progress bar.
|
||||
for p in partial_snapshots:
|
||||
assert p["scan_meta"].get("sessions_expected_total") == 750
|
||||
|
||||
# Final snapshot in cache is the real (non-partial) one.
|
||||
final = plugin_api._SNAPSHOT_CACHE
|
||||
assert final is not None
|
||||
assert final["scan_meta"].get("mode") != "in_progress"
|
||||
assert final["scan_meta"].get("sessions_total") == 750
|
||||
|
||||
|
||||
def test_partial_snapshots_do_not_persist_unlock_timestamps(plugin_api):
|
||||
"""Intermediate snapshots must not write to state.json — an unlock
|
||||
that appears at 30% scan progress could disappear when a later session
|
||||
rebalances the aggregate. Only the final snapshot records ``unlocked_at``.
|
||||
"""
|
||||
fake_db = _FakeSessionDB(session_count=10)
|
||||
_install_fake_session_db(plugin_api, fake_db)
|
||||
|
||||
# Seed empty state, then invoke partial compute directly.
|
||||
plugin_api.save_state({"unlocks": {}})
|
||||
partial_scan = {
|
||||
"sessions": [{"session_id": "x", "tool_call_count": 99999, "tool_names": set()}],
|
||||
"aggregate": {"max_tool_calls_in_session": 99999, "total_tool_calls": 99999},
|
||||
"scan_meta": {"mode": "in_progress"},
|
||||
}
|
||||
result = plugin_api._compute_from_scan(partial_scan, is_partial=True)
|
||||
|
||||
# Some achievements should evaluate as unlocked in this aggregate...
|
||||
assert any(a["unlocked"] for a in result["achievements"])
|
||||
|
||||
# ...but state.json on disk stays empty (no timestamps were recorded).
|
||||
persisted = plugin_api.load_state()
|
||||
assert persisted.get("unlocks", {}) == {}, (
|
||||
"partial scans must not record unlock timestamps — a later session "
|
||||
"could change whether the badge deserves to be unlocked yet"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Unit tests for the Chronos NAS-mediated cron provider (Phase 4D).
|
||||
|
||||
All NAS calls are mocked — ZERO live network. These prove:
|
||||
- is_available is config-only (no network), false without config.
|
||||
- one-shot arming sends the right provision payload (incl. sub-minute fires —
|
||||
the agent owns the time, so there's no 1-minute floor).
|
||||
- reconcile arms missing, cancels orphaned, skips paused.
|
||||
- fire_due re-arms the next one-shot after a successful run, and repeat-N
|
||||
(job gone) stops re-arming.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chronos(monkeypatch):
|
||||
"""A ChronosCronScheduler with a fake NAS client capturing calls."""
|
||||
from plugins.cron_providers.chronos import ChronosCronScheduler
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.provisions = []
|
||||
self.cancels = []
|
||||
self._armed = []
|
||||
|
||||
def provision(self, *, job_id, fire_at, agent_callback_url, dedup_key):
|
||||
self.provisions.append({
|
||||
"job_id": job_id, "fire_at": fire_at,
|
||||
"agent_callback_url": agent_callback_url, "dedup_key": dedup_key,
|
||||
})
|
||||
return {"schedule_id": f"sched-{job_id}"}
|
||||
|
||||
def cancel(self, *, job_id):
|
||||
self.cancels.append(job_id)
|
||||
return {}
|
||||
|
||||
def list_armed(self):
|
||||
return list(self._armed)
|
||||
|
||||
prov = ChronosCronScheduler()
|
||||
fake = FakeClient()
|
||||
prov._client = fake
|
||||
# callback_url is read via _cfg; patch the module helper to avoid config.
|
||||
monkeypatch.setattr("plugins.cron_providers.chronos._cfg",
|
||||
lambda *k, default="": "https://agent.example/" if k[-1] == "callback_url" else "https://portal.test")
|
||||
return prov, fake
|
||||
|
||||
|
||||
# -- is_available -------------------------------------------------------------
|
||||
|
||||
def test_is_available_false_without_config(temp_home, monkeypatch):
|
||||
from plugins.cron_providers.chronos import ChronosCronScheduler
|
||||
|
||||
monkeypatch.setattr("plugins.cron_providers.chronos._cfg", lambda *k, default="": "")
|
||||
assert ChronosCronScheduler().is_available() is False
|
||||
|
||||
|
||||
def test_is_available_true_with_config_and_token(temp_home, monkeypatch):
|
||||
import plugins.cron_providers.chronos as mod
|
||||
from plugins.cron_providers.chronos import ChronosCronScheduler
|
||||
|
||||
monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x" )
|
||||
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state",
|
||||
lambda pid: {"access_token": "tok"})
|
||||
assert ChronosCronScheduler().is_available() is True
|
||||
|
||||
|
||||
def test_is_available_makes_no_network(temp_home, monkeypatch):
|
||||
"""is_available must not construct the NAS client / hit network."""
|
||||
import plugins.cron_providers.chronos as mod
|
||||
from plugins.cron_providers.chronos import ChronosCronScheduler
|
||||
|
||||
monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x")
|
||||
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state",
|
||||
lambda pid: {"access_token": "tok"})
|
||||
p = ChronosCronScheduler()
|
||||
|
||||
def explode():
|
||||
raise AssertionError("is_available must not build the NAS client")
|
||||
|
||||
monkeypatch.setattr(p, "_get_client", explode)
|
||||
assert p.is_available() is True # did not call _get_client
|
||||
|
||||
|
||||
# -- arming -------------------------------------------------------------------
|
||||
|
||||
def test_arm_one_shot_sends_provision(chronos):
|
||||
prov, fake = chronos
|
||||
prov._arm_one_shot({"id": "j1", "next_run_at": "2026-06-18T12:00:00+00:00"})
|
||||
|
||||
assert len(fake.provisions) == 1
|
||||
p = fake.provisions[0]
|
||||
assert p["job_id"] == "j1"
|
||||
assert p["fire_at"] == "2026-06-18T12:00:00+00:00"
|
||||
assert p["dedup_key"] == "j1:2026-06-18T12:00:00+00:00"
|
||||
assert p["agent_callback_url"] == "https://agent.example/"
|
||||
|
||||
|
||||
def test_arm_one_shot_preserves_sub_minute_fire(chronos):
|
||||
"""Sub-minute fire times survive — the agent owns the time, so there's no
|
||||
1-minute scheduler floor."""
|
||||
prov, fake = chronos
|
||||
prov._arm_one_shot({"id": "j2", "next_run_at": "2026-06-18T12:00:30+00:00"})
|
||||
assert fake.provisions[0]["fire_at"] == "2026-06-18T12:00:30+00:00"
|
||||
|
||||
|
||||
def test_arm_one_shot_noop_without_next_run(chronos):
|
||||
prov, fake = chronos
|
||||
prov._arm_one_shot({"id": "j3", "next_run_at": None})
|
||||
assert fake.provisions == []
|
||||
|
||||
|
||||
# -- reconcile ----------------------------------------------------------------
|
||||
|
||||
def test_reconcile_arms_all_enabled(temp_home, chronos, monkeypatch):
|
||||
prov, fake = chronos
|
||||
jobs = [
|
||||
{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"},
|
||||
{"id": "b", "enabled": True, "next_run_at": "2026-06-18T12:05:00+00:00", "state": "scheduled"},
|
||||
]
|
||||
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
|
||||
monkeypatch.setattr("cron.jobs.get_job", lambda jid: next(j for j in jobs if j["id"] == jid))
|
||||
|
||||
prov.reconcile()
|
||||
assert {p["job_id"] for p in fake.provisions} == {"a", "b"}
|
||||
assert fake.cancels == []
|
||||
|
||||
|
||||
def test_reconcile_cancels_orphan_arms_desired(temp_home, chronos, monkeypatch):
|
||||
prov, fake = chronos
|
||||
# NAS already has a stale arm for deleted job "gone".
|
||||
prov._armed = {"gone": "2026-06-18T11:00:00+00:00"}
|
||||
jobs = [{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}]
|
||||
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
|
||||
monkeypatch.setattr("cron.jobs.get_job", lambda jid: next((j for j in jobs if j["id"] == jid), None))
|
||||
|
||||
prov.reconcile()
|
||||
assert [p["job_id"] for p in fake.provisions] == ["a"]
|
||||
assert fake.cancels == ["gone"]
|
||||
|
||||
|
||||
def test_reconcile_skips_paused(temp_home, chronos, monkeypatch):
|
||||
prov, fake = chronos
|
||||
jobs = [{"id": "p", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "paused"}]
|
||||
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
|
||||
monkeypatch.setattr("cron.jobs.get_job", lambda jid: next((j for j in jobs if j["id"] == jid), None))
|
||||
|
||||
prov.reconcile()
|
||||
assert fake.provisions == []
|
||||
|
||||
|
||||
def test_reconcile_skips_already_armed_same_time(temp_home, chronos, monkeypatch):
|
||||
prov, fake = chronos
|
||||
prov._armed = {"a": "2026-06-18T12:00:00+00:00"}
|
||||
jobs = [{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}]
|
||||
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
|
||||
monkeypatch.setattr("cron.jobs.get_job", lambda jid: jobs[0])
|
||||
|
||||
prov.reconcile()
|
||||
assert fake.provisions == [] # already armed at the same time → no re-arm
|
||||
|
||||
|
||||
# -- fire_due re-arm ----------------------------------------------------------
|
||||
|
||||
def test_fire_due_rearms_next_oneshot(chronos, monkeypatch):
|
||||
prov, fake = chronos
|
||||
# super().fire_due runs the job; stub the ABC default to "ran".
|
||||
monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due",
|
||||
lambda self, jid, **kw: True)
|
||||
monkeypatch.setattr("cron.jobs.get_job",
|
||||
lambda jid: {"id": jid, "enabled": True, "next_run_at": "2026-06-18T12:05:00+00:00"})
|
||||
|
||||
assert prov.fire_due("j1") is True
|
||||
assert [p["job_id"] for p in fake.provisions] == ["j1"]
|
||||
assert fake.provisions[0]["fire_at"] == "2026-06-18T12:05:00+00:00"
|
||||
|
||||
|
||||
def test_fire_due_no_rearm_when_job_gone(chronos, monkeypatch):
|
||||
"""repeat-N exhausted / one-shot completed → mark_job_run deleted the job →
|
||||
get_job None → no re-arm (the schedule stops cleanly)."""
|
||||
prov, fake = chronos
|
||||
monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due",
|
||||
lambda self, jid, **kw: True)
|
||||
monkeypatch.setattr("cron.jobs.get_job", lambda jid: None)
|
||||
|
||||
assert prov.fire_due("j1") is True
|
||||
assert fake.provisions == []
|
||||
|
||||
|
||||
def test_fire_due_no_rearm_when_claim_lost(chronos, monkeypatch):
|
||||
"""If the run didn't happen (claim lost), don't re-arm."""
|
||||
prov, fake = chronos
|
||||
monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due",
|
||||
lambda self, jid, **kw: False)
|
||||
|
||||
assert prov.fire_due("j1") is False
|
||||
assert fake.provisions == []
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for the Chronos inbound cron-fire JWT verifier (Phase 4E.1).
|
||||
|
||||
These exercise REAL RS256 signing/verification (PyJWT[crypto] is a declared
|
||||
dependency) against an inline PEM public key — no mocking of the crypto, since
|
||||
this is a security boundary. The JWKS-URL path is covered separately by mocking
|
||||
PyJWKClient's key resolution.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keys():
|
||||
"""An RS256 keypair: (private_pem, public_pem)."""
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
priv = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
pub = key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode()
|
||||
return priv, pub
|
||||
|
||||
|
||||
def _mint(priv, claims):
|
||||
import jwt
|
||||
return jwt.encode(claims, priv, algorithm="RS256")
|
||||
|
||||
|
||||
AUD = "agent:inst-123"
|
||||
ISS = "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def _base_claims(**over):
|
||||
now = int(time.time())
|
||||
c = {
|
||||
"aud": AUD,
|
||||
"iss": ISS,
|
||||
"purpose": "cron_fire",
|
||||
"iat": now,
|
||||
"nbf": now - 5,
|
||||
"exp": now + 300,
|
||||
}
|
||||
c.update(over)
|
||||
return c
|
||||
|
||||
|
||||
def test_valid_token_returns_claims(rsa_keys):
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
token = _mint(priv, _base_claims())
|
||||
claims = verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS)
|
||||
assert claims is not None
|
||||
assert claims["purpose"] == "cron_fire"
|
||||
assert claims["aud"] == AUD
|
||||
|
||||
|
||||
def test_wrong_audience_rejected(rsa_keys):
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
token = _mint(priv, _base_claims(aud="agent:someone-else"))
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS) is None
|
||||
|
||||
|
||||
def test_missing_purpose_rejected(rsa_keys):
|
||||
"""A general agent JWT (no purpose=cron_fire) can't fire jobs."""
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
claims = _base_claims()
|
||||
del claims["purpose"]
|
||||
token = _mint(priv, claims)
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS) is None
|
||||
|
||||
|
||||
def test_wrong_purpose_rejected(rsa_keys):
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
token = _mint(priv, _base_claims(purpose="inference"))
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS) is None
|
||||
|
||||
|
||||
def test_expired_token_rejected(rsa_keys):
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
now = int(time.time())
|
||||
token = _mint(priv, _base_claims(iat=now - 1000, nbf=now - 1000, exp=now - 600))
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS) is None
|
||||
|
||||
|
||||
def test_wrong_issuer_rejected(rsa_keys):
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
token = _mint(priv, _base_claims(iss="https://evil.example"))
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS) is None
|
||||
|
||||
|
||||
def test_tampered_signature_rejected(rsa_keys):
|
||||
"""A token signed by a DIFFERENT key must fail signature verification."""
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
_, pub = rsa_keys
|
||||
attacker = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
attacker_priv = attacker.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode()
|
||||
token = _mint(attacker_priv, _base_claims())
|
||||
# Verified against the REAL public key → signature mismatch → None.
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=pub, issuer=ISS) is None
|
||||
|
||||
|
||||
def test_no_key_configured_refuses(rsa_keys):
|
||||
"""No JWKS/key configured → refuse (never fall back to unsigned decode)."""
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, _ = rsa_keys
|
||||
token = _mint(priv, _base_claims())
|
||||
assert verify_nas_fire_token(token=token, expected_audience=AUD,
|
||||
jwks_or_key=None) is None
|
||||
|
||||
|
||||
def test_empty_token_refused(rsa_keys):
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
_, pub = rsa_keys
|
||||
assert verify_nas_fire_token(token="", expected_audience=AUD, jwks_or_key=pub) is None
|
||||
|
||||
|
||||
def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch):
|
||||
"""The JWKS-URL branch resolves the signing key via PyJWKClient."""
|
||||
from plugins.cron_providers.chronos.verify import verify_nas_fire_token
|
||||
|
||||
priv, pub = rsa_keys
|
||||
token = _mint(priv, _base_claims())
|
||||
|
||||
class FakeKey:
|
||||
key = pub
|
||||
|
||||
class FakeJWKClient:
|
||||
def __init__(self, url):
|
||||
assert url == "https://portal.nousresearch.com/.well-known/jwks.json"
|
||||
|
||||
def get_signing_key_from_jwt(self, tok):
|
||||
return FakeKey()
|
||||
|
||||
monkeypatch.setattr("jwt.PyJWKClient", FakeJWKClient)
|
||||
claims = verify_nas_fire_token(
|
||||
token=token, expected_audience=AUD,
|
||||
jwks_or_key="https://portal.nousresearch.com/.well-known/jwks.json",
|
||||
issuer=ISS,
|
||||
)
|
||||
assert claims is not None and claims["purpose"] == "cron_fire"
|
||||
|
||||
|
||||
def test_get_fire_verifier_returns_nas_verifier():
|
||||
from plugins.cron_providers.chronos.verify import get_fire_verifier, verify_nas_fire_token
|
||||
|
||||
assert get_fire_verifier() is verify_nas_fire_token
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_task_runtime_exit_notifies_gateway_for_reconnect(monkeypatch):
|
||||
"""A post-ready discord.py websocket task crash must not leave the gateway split-brained.
|
||||
|
||||
Regression: producers stayed systemd-active while Discord stopped responding after
|
||||
a runtime ClientOSError/ConnectionResetError. The adapter must mark Discord as a
|
||||
retryable fatal platform error and notify the gateway supervisor so the existing
|
||||
reconnect watcher can replace the dead adapter.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
|
||||
adapter._running = True
|
||||
adapter._ready_event.set()
|
||||
adapter._notify_fatal_error = AsyncMock()
|
||||
|
||||
async def crash():
|
||||
raise ConnectionResetError("Cannot write to closing transport")
|
||||
|
||||
task = asyncio.create_task(crash())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter._handle_bot_task_done(task)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert adapter.fatal_error_code == "discord_gateway_task_exited"
|
||||
assert adapter.fatal_error_message is not None
|
||||
assert "Cannot write to closing transport" in adapter.fatal_error_message
|
||||
adapter._notify_fatal_error.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_task_done_ignored_during_intentional_disconnect():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
|
||||
adapter._running = True
|
||||
adapter._ready_event.set()
|
||||
adapter._disconnecting = True
|
||||
adapter._notify_fatal_error = AsyncMock()
|
||||
|
||||
async def stop_cleanly():
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(stop_cleanly())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter._handle_bot_task_done(task)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
adapter._notify_fatal_error.assert_not_awaited()
|
||||
@@ -0,0 +1,645 @@
|
||||
"""Tests for the disk-cleanup plugin.
|
||||
|
||||
Covers the bundled plugin at ``plugins/disk-cleanup/``:
|
||||
|
||||
* ``disk_cleanup`` library: track / forget / dry_run / quick / status,
|
||||
``is_safe_path`` and ``guess_category`` filtering.
|
||||
* Plugin ``__init__``: ``post_tool_call`` hook auto-tracks files created
|
||||
by ``write_file`` / ``terminal``; ``on_session_end`` hook runs quick
|
||||
cleanup when anything was tracked during the turn.
|
||||
* Slash command handler: status / dry-run / quick / track / forget /
|
||||
unknown subcommand behaviours.
|
||||
* Bundled-plugin discovery via ``PluginManager.discover_and_load``.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(tmp_path, monkeypatch):
|
||||
"""Isolate HERMES_HOME for each test.
|
||||
|
||||
The global hermetic fixture already redirects HERMES_HOME to a tempdir,
|
||||
but we want the plugin to work with a predictable subpath. We reset
|
||||
HERMES_HOME here for clarity.
|
||||
"""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
yield hermes_home
|
||||
|
||||
|
||||
def _load_lib():
|
||||
"""Import the plugin's library module directly from the repo path."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
lib_path = repo_root / "plugins" / "disk-cleanup" / "disk_cleanup.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"disk_cleanup_under_test", lib_path
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_plugin_init():
|
||||
"""Import the plugin's __init__.py (which depends on the library)."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_dir = repo_root / "plugins" / "disk-cleanup"
|
||||
# Use the PluginManager's module naming convention so relative imports work.
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_plugins.disk_cleanup",
|
||||
plugin_dir / "__init__.py",
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
# Ensure parent namespace package exists for the relative `. import disk_cleanup`
|
||||
import types
|
||||
if "hermes_plugins" not in sys.modules:
|
||||
ns = types.ModuleType("hermes_plugins")
|
||||
ns.__path__ = []
|
||||
sys.modules["hermes_plugins"] = ns
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
mod.__package__ = "hermes_plugins.disk_cleanup"
|
||||
mod.__path__ = [str(plugin_dir)]
|
||||
sys.modules["hermes_plugins.disk_cleanup"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsSafePath:
|
||||
def test_accepts_path_under_hermes_home(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "subdir" / "file.txt"
|
||||
p.parent.mkdir()
|
||||
p.write_text("x")
|
||||
assert dg.is_safe_path(p) is True
|
||||
|
||||
def test_rejects_outside_hermes_home(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
assert dg.is_safe_path(Path("/etc/passwd")) is False
|
||||
|
||||
def test_accepts_tmp_hermes_prefix(self, _isolate_env, tmp_path):
|
||||
dg = _load_lib()
|
||||
assert dg.is_safe_path(Path("/tmp/hermes-abc/x.log")) is True
|
||||
|
||||
def test_rejects_plain_tmp(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
assert dg.is_safe_path(Path("/tmp/other.log")) is False
|
||||
|
||||
def test_rejects_windows_mount(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
assert dg.is_safe_path(Path("/mnt/c/Users/x/test.txt")) is False
|
||||
|
||||
|
||||
class TestGuessCategory:
|
||||
def test_test_prefix(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "test_foo.py"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) == "test"
|
||||
|
||||
def test_tmp_prefix(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "tmp_foo.log"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) == "test"
|
||||
|
||||
def test_dot_test_suffix(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "mything.test.js"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) == "test"
|
||||
|
||||
def test_skips_protected_top_level(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
logs_dir = _isolate_env / "logs"
|
||||
logs_dir.mkdir()
|
||||
p = logs_dir / "test_log.txt"
|
||||
p.write_text("x")
|
||||
# Even though it matches test_* pattern, logs/ is excluded.
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cron_subtree_categorised(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
# Only files under ``cron/output/`` are disposable run artifacts.
|
||||
output_dir = _isolate_env / "cron" / "output" / "job_123"
|
||||
output_dir.mkdir(parents=True)
|
||||
p = output_dir / "run.md"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) == "cron-output"
|
||||
|
||||
def test_cron_output_root_not_tracked(self, _isolate_env):
|
||||
"""The cron/output root is durable container state, not an artifact."""
|
||||
dg = _load_lib()
|
||||
output_root = _isolate_env / "cron" / "output"
|
||||
output_root.mkdir(parents=True)
|
||||
assert dg.guess_category(output_root) is None
|
||||
|
||||
def test_cron_jobs_json_not_tracked(self, _isolate_env):
|
||||
"""Regression for #32164: the cron registry must never be tracked."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "jobs.json"
|
||||
p.write_text("[]")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cron_tick_lock_not_tracked(self, _isolate_env):
|
||||
"""Regression for #32164: cron tick-lock is control-plane state."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / ".tick.lock"
|
||||
p.write_text("")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cronjobs_top_level_not_tracked(self, _isolate_env):
|
||||
"""The legacy ``cronjobs`` alias is also control-plane at the top."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cronjobs"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "jobs.json"
|
||||
p.write_text("[]")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_ordinary_file_returns_none(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "notes.md"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
|
||||
class TestStaleCronEntryMigration:
|
||||
"""Regression tests for #37721 — stale cron-output entries in tracked.json."""
|
||||
|
||||
def test_quick_skips_stale_cron_output_for_jobs_json(self, _isolate_env):
|
||||
"""A stale tracked.json entry with category="cron-output" for
|
||||
cron/jobs.json must NOT be deleted by quick().
|
||||
|
||||
This is the exact scenario from #37721: an old tracked.json has
|
||||
{"path": ".../cron/jobs.json", "category": "cron-output"} which
|
||||
would pass the delete filter but must be skipped because
|
||||
guess_category() now returns None for non-output cron paths.
|
||||
"""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
jobs_json = cron_dir / "jobs.json"
|
||||
jobs_json.write_text('{"jobs": []}')
|
||||
|
||||
# Simulate a stale tracked.json entry from before #34840 by
|
||||
# directly writing the tracked file (track() would reject it).
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
tracked_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tracked_file.write_text(json.dumps([{
|
||||
"path": str(jobs_json),
|
||||
"category": "cron-output",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00", # very old
|
||||
"size": 123,
|
||||
}]))
|
||||
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 0, "cron/jobs.json must not be deleted"
|
||||
assert jobs_json.exists(), "jobs.json must still exist"
|
||||
# The stale entry should have been dropped from tracking.
|
||||
remaining = json.loads(tracked_file.read_text())
|
||||
assert len(remaining) == 0
|
||||
|
||||
def test_quick_skips_stale_cron_output_for_cron_dir(self, _isolate_env):
|
||||
"""Stale entry for the cron/ directory itself must not be deleted."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
output_dir = cron_dir / "output"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "run.md").write_text("x")
|
||||
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
tracked_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tracked_file.write_text(json.dumps([{
|
||||
"path": str(cron_dir),
|
||||
"category": "cron-output",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"size": 0,
|
||||
}]))
|
||||
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 0, "cron/ dir must not be deleted"
|
||||
assert cron_dir.exists()
|
||||
|
||||
def test_quick_skips_stale_cron_output_for_output_root(self, _isolate_env):
|
||||
"""Stale entry for cron/output itself must not delete all job output."""
|
||||
dg = _load_lib()
|
||||
output_root = _isolate_env / "cron" / "output"
|
||||
job_dir = output_root / "job_1"
|
||||
job_dir.mkdir(parents=True)
|
||||
run_md = job_dir / "run.md"
|
||||
run_md.write_text("x")
|
||||
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
tracked_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tracked_file.write_text(json.dumps([{
|
||||
"path": str(output_root),
|
||||
"category": "cron-output",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"size": 0,
|
||||
}]))
|
||||
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 0, "cron/output root must not be deleted"
|
||||
assert output_root.exists()
|
||||
assert run_md.exists()
|
||||
|
||||
def test_quick_skips_protected_cron_paths_defense_in_depth(self, _isolate_env):
|
||||
"""Defense-in-depth: even if guess_category returned cron-output
|
||||
(hypothetically), protected cron paths are never deleted."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
tick_lock = cron_dir / ".tick.lock"
|
||||
tick_lock.write_text("")
|
||||
|
||||
# Manually inject a stale entry with "test" category (would normally
|
||||
# be auto-deleted) — the protected path guard must still block it.
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
tracked_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tracked_file.write_text(json.dumps([{
|
||||
"path": str(tick_lock),
|
||||
"category": "test",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"size": 0,
|
||||
}]))
|
||||
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 0, ".tick.lock must not be deleted"
|
||||
assert tick_lock.exists()
|
||||
|
||||
def test_dry_run_omits_stale_cron_output(self, _isolate_env):
|
||||
"""dry_run() should also skip stale cron-output entries."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
jobs_json = cron_dir / "jobs.json"
|
||||
jobs_json.write_text("[]")
|
||||
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
tracked_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tracked_file.write_text(json.dumps([{
|
||||
"path": str(jobs_json),
|
||||
"category": "cron-output",
|
||||
"timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"size": 123,
|
||||
}]))
|
||||
|
||||
auto, prompt = dg.dry_run()
|
||||
assert len(auto) == 0, "stale cron-output for jobs.json must not appear"
|
||||
assert len(prompt) == 0
|
||||
|
||||
def test_legitimate_cron_output_still_deleted(self, _isolate_env):
|
||||
"""A valid cron-output entry under cron/output/ must still be deleted."""
|
||||
dg = _load_lib()
|
||||
output_dir = _isolate_env / "cron" / "output" / "job_1"
|
||||
output_dir.mkdir(parents=True)
|
||||
run_md = output_dir / "run.md"
|
||||
run_md.write_text("x")
|
||||
|
||||
# Old enough to be deleted (>14 days)
|
||||
from datetime import datetime, timezone, timedelta
|
||||
old_ts = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat()
|
||||
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
tracked_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tracked_file.write_text(json.dumps([{
|
||||
"path": str(run_md),
|
||||
"category": "cron-output",
|
||||
"timestamp": old_ts,
|
||||
"size": 10,
|
||||
}]))
|
||||
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 1, "valid old cron-output should be deleted"
|
||||
assert not run_md.exists()
|
||||
|
||||
|
||||
class TestTrackForgetQuick:
|
||||
def test_track_then_quick_deletes_test(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "test_a.py"
|
||||
p.write_text("x")
|
||||
assert dg.track(str(p), "test", silent=True) is True
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 1
|
||||
assert not p.exists()
|
||||
|
||||
def test_track_dedup(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "test_a.py"
|
||||
p.write_text("x")
|
||||
assert dg.track(str(p), "test", silent=True) is True
|
||||
# Second call returns False (already tracked)
|
||||
assert dg.track(str(p), "test", silent=True) is False
|
||||
|
||||
def test_track_rejects_outside_home(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
# /etc/hostname exists on most Linux boxes; fall back if not.
|
||||
outside = "/etc/hostname" if Path("/etc/hostname").exists() else "/etc/passwd"
|
||||
assert dg.track(outside, "test", silent=True) is False
|
||||
|
||||
def test_track_skips_missing(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
assert dg.track(str(_isolate_env / "nope.txt"), "test", silent=True) is False
|
||||
|
||||
def test_forget_removes_entry(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "keep.tmp"
|
||||
p.write_text("x")
|
||||
dg.track(str(p), "temp", silent=True)
|
||||
assert dg.forget(str(p)) == 1
|
||||
assert p.exists() # forget does NOT delete the file
|
||||
|
||||
def test_quick_preserves_unexpired_temp(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "fresh.tmp"
|
||||
p.write_text("x")
|
||||
dg.track(str(p), "temp", silent=True)
|
||||
summary = dg.quick()
|
||||
assert summary["deleted"] == 0
|
||||
assert p.exists()
|
||||
|
||||
def test_quick_preserves_protected_top_level_dirs(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
for d in ("logs", "memories", "sessions", "cron", "cache"):
|
||||
(_isolate_env / d).mkdir()
|
||||
dg.quick()
|
||||
for d in ("logs", "memories", "sessions", "cron", "cache"):
|
||||
assert (_isolate_env / d).exists(), f"{d}/ should be preserved"
|
||||
|
||||
def test_quick_does_not_descend_into_protected_top_level_dirs(
|
||||
self, _isolate_env, monkeypatch
|
||||
):
|
||||
dg = _load_lib()
|
||||
protected_empty = (
|
||||
_isolate_env / "hermes-agent" / "node_modules" / "pkg" / "empty"
|
||||
)
|
||||
protected_empty.mkdir(parents=True)
|
||||
|
||||
original_iterdir = Path.iterdir
|
||||
|
||||
def guarded_iterdir(path):
|
||||
if path == _isolate_env / "hermes-agent":
|
||||
raise AssertionError("quick() descended into protected hermes-agent/")
|
||||
return original_iterdir(path)
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", guarded_iterdir)
|
||||
|
||||
dg.quick()
|
||||
|
||||
assert protected_empty.exists()
|
||||
|
||||
def test_quick_removes_empty_dirs_in_managed_subtrees(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
managed_empty = _isolate_env / "scratch" / "nested" / "empty"
|
||||
managed_empty.mkdir(parents=True)
|
||||
|
||||
dg.quick()
|
||||
|
||||
assert not (_isolate_env / "scratch").exists()
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_empty_status(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
s = dg.status()
|
||||
assert s["total_tracked"] == 0
|
||||
assert s["top10"] == []
|
||||
|
||||
def test_status_with_entries(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "big.tmp"
|
||||
p.write_text("y" * 100)
|
||||
dg.track(str(p), "temp", silent=True)
|
||||
s = dg.status()
|
||||
assert s["total_tracked"] == 1
|
||||
assert len(s["top10"]) == 1
|
||||
rendered = dg.format_status(s)
|
||||
assert "temp" in rendered
|
||||
assert "big.tmp" in rendered
|
||||
|
||||
|
||||
class TestDryRun:
|
||||
def test_classifies_by_category(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
test_f = _isolate_env / "test_x.py"
|
||||
test_f.write_text("x")
|
||||
big = _isolate_env / "big.bin"
|
||||
big.write_bytes(b"z" * 10)
|
||||
dg.track(str(test_f), "test", silent=True)
|
||||
dg.track(str(big), "other", silent=True)
|
||||
auto, prompt = dg.dry_run()
|
||||
# test → auto, other → neither (doesn't hit any rule)
|
||||
assert any(i["path"] == str(test_f) for i in auto)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin hooks tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPostToolCallHook:
|
||||
def test_write_file_test_pattern_tracked(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
p = _isolate_env / "test_created.py"
|
||||
p.write_text("x")
|
||||
pi._on_post_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": str(p), "content": "x"},
|
||||
result="OK",
|
||||
task_id="t1", session_id="s1",
|
||||
)
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
data = json.loads(tracked_file.read_text())
|
||||
assert len(data) == 1
|
||||
assert data[0]["category"] == "test"
|
||||
|
||||
def test_write_file_non_test_not_tracked(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
p = _isolate_env / "notes.md"
|
||||
p.write_text("x")
|
||||
pi._on_post_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": str(p), "content": "x"},
|
||||
result="OK",
|
||||
task_id="t2", session_id="s2",
|
||||
)
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]"
|
||||
|
||||
def test_terminal_command_picks_up_paths(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
p = _isolate_env / "tmp_created.log"
|
||||
p.write_text("x")
|
||||
pi._on_post_tool_call(
|
||||
tool_name="terminal",
|
||||
args={"command": f"touch {p}"},
|
||||
result=f"created {p}\n",
|
||||
task_id="t3", session_id="s3",
|
||||
)
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
data = json.loads(tracked_file.read_text())
|
||||
assert any(Path(i["path"]) == p.resolve() for i in data)
|
||||
|
||||
def test_ignores_unrelated_tool(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
pi._on_post_tool_call(
|
||||
tool_name="read_file",
|
||||
args={"path": str(_isolate_env / "test_x.py")},
|
||||
result="contents",
|
||||
task_id="t4", session_id="s4",
|
||||
)
|
||||
# read_file should never trigger tracking.
|
||||
tracked_file = _isolate_env / "disk-cleanup" / "tracked.json"
|
||||
assert not tracked_file.exists() or tracked_file.read_text().strip() == "[]"
|
||||
|
||||
|
||||
class TestOnSessionEndHook:
|
||||
def test_runs_quick_when_test_files_tracked(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
p = _isolate_env / "test_cleanup.py"
|
||||
p.write_text("x")
|
||||
pi._on_post_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": str(p), "content": "x"},
|
||||
result="OK",
|
||||
task_id="", session_id="s1",
|
||||
)
|
||||
assert p.exists()
|
||||
pi._on_session_end(session_id="s1", completed=True, interrupted=False)
|
||||
assert not p.exists(), "test file should be auto-deleted"
|
||||
|
||||
def test_noop_when_no_test_tracked(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
# Nothing tracked → on_session_end should not raise.
|
||||
pi._on_session_end(session_id="empty", completed=True, interrupted=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slash command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlashCommand:
|
||||
def test_help(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
out = pi._handle_slash("help")
|
||||
assert "disk-cleanup" in out
|
||||
assert "status" in out
|
||||
|
||||
def test_status_empty(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
out = pi._handle_slash("status")
|
||||
assert "nothing tracked" in out
|
||||
|
||||
def test_track_rejects_missing(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
out = pi._handle_slash(
|
||||
f"track {_isolate_env / 'nope.txt'} temp"
|
||||
)
|
||||
assert "Not tracked" in out
|
||||
|
||||
def test_track_rejects_bad_category(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
p = _isolate_env / "a.tmp"
|
||||
p.write_text("x")
|
||||
out = pi._handle_slash(f"track {p} banana")
|
||||
assert "Unknown category" in out
|
||||
|
||||
def test_track_and_forget(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
p = _isolate_env / "a.tmp"
|
||||
p.write_text("x")
|
||||
out = pi._handle_slash(f"track {p} temp")
|
||||
assert "Tracked" in out
|
||||
out = pi._handle_slash(f"forget {p}")
|
||||
assert "Removed 1" in out
|
||||
|
||||
def test_unknown_subcommand(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
out = pi._handle_slash("foobar")
|
||||
assert "Unknown subcommand" in out
|
||||
|
||||
def test_quick_on_empty(self, _isolate_env):
|
||||
pi = _load_plugin_init()
|
||||
out = pi._handle_slash("quick")
|
||||
assert "Cleaned 0 files" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled-plugin discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBundledDiscovery:
|
||||
def _write_enabled_config(self, hermes_home, names):
|
||||
"""Write plugins.enabled allow-list to config.yaml."""
|
||||
import yaml
|
||||
cfg_path = hermes_home / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({"plugins": {"enabled": list(names)}}))
|
||||
|
||||
def test_disk_cleanup_discovered_but_not_loaded_by_default(self, _isolate_env):
|
||||
"""Bundled plugins are discovered but NOT loaded without opt-in."""
|
||||
from hermes_cli import plugins as pmod
|
||||
mgr = pmod.PluginManager()
|
||||
mgr.discover_and_load()
|
||||
# Discovered — appears in the registry
|
||||
assert "disk-cleanup" in mgr._plugins
|
||||
loaded = mgr._plugins["disk-cleanup"]
|
||||
assert loaded.manifest.source == "bundled"
|
||||
# But NOT enabled — no hooks or commands registered
|
||||
assert not loaded.enabled
|
||||
assert loaded.error and "not enabled" in loaded.error
|
||||
|
||||
def test_disk_cleanup_loads_when_enabled(self, _isolate_env):
|
||||
"""Adding to plugins.enabled activates the bundled plugin."""
|
||||
self._write_enabled_config(_isolate_env, ["disk-cleanup"])
|
||||
from hermes_cli import plugins as pmod
|
||||
mgr = pmod.PluginManager()
|
||||
mgr.discover_and_load()
|
||||
loaded = mgr._plugins["disk-cleanup"]
|
||||
assert loaded.enabled
|
||||
assert "post_tool_call" in loaded.hooks_registered
|
||||
assert "on_session_end" in loaded.hooks_registered
|
||||
assert "disk-cleanup" in loaded.commands_registered
|
||||
|
||||
def test_disabled_beats_enabled(self, _isolate_env):
|
||||
"""plugins.disabled wins even if the plugin is also in plugins.enabled."""
|
||||
import yaml
|
||||
cfg_path = _isolate_env / "config.yaml"
|
||||
cfg_path.write_text(yaml.safe_dump({
|
||||
"plugins": {
|
||||
"enabled": ["disk-cleanup"],
|
||||
"disabled": ["disk-cleanup"],
|
||||
}
|
||||
}))
|
||||
from hermes_cli import plugins as pmod
|
||||
mgr = pmod.PluginManager()
|
||||
mgr.discover_and_load()
|
||||
loaded = mgr._plugins["disk-cleanup"]
|
||||
assert not loaded.enabled
|
||||
assert loaded.error == "disabled via config"
|
||||
|
||||
def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env):
|
||||
"""Bundled scan must NOT pick up plugins/memory or plugins/context_engine
|
||||
as top-level plugins — they have their own discovery paths."""
|
||||
self._write_enabled_config(
|
||||
_isolate_env, ["memory", "context_engine", "disk-cleanup"]
|
||||
)
|
||||
from hermes_cli import plugins as pmod
|
||||
mgr = pmod.PluginManager()
|
||||
mgr.discover_and_load()
|
||||
assert "memory" not in mgr._plugins
|
||||
assert "context_engine" not in mgr._plugins
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Tests for plugins.google_meet.audio_bridge (v2).
|
||||
|
||||
Covers the platform gating and pactl / system_profiler plumbing
|
||||
without actually invoking those tools on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_home(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
yield hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linux setup / teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _linux_pactl_result(stdout: str) -> MagicMock:
|
||||
"""Build a fake CompletedProcess-ish object for subprocess.run."""
|
||||
m = MagicMock()
|
||||
m.stdout = stdout
|
||||
m.stderr = ""
|
||||
m.returncode = 0
|
||||
return m
|
||||
|
||||
|
||||
def test_setup_linux_loads_null_sink_and_virtual_source():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
# First call = null-sink → module id 42
|
||||
# Second call = virtual-source → module id 43
|
||||
if "module-null-sink" in argv:
|
||||
return _linux_pactl_result("42\n")
|
||||
if "module-virtual-source" in argv:
|
||||
return _linux_pactl_result("43\n")
|
||||
raise AssertionError(f"unexpected pactl invocation: {argv}")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_fake_run):
|
||||
br = AudioBridge()
|
||||
info = br.setup()
|
||||
|
||||
# Two pactl load-module calls, in order.
|
||||
assert len(calls) == 2
|
||||
assert calls[0][0] == "pactl" and calls[0][1] == "load-module"
|
||||
assert "module-null-sink" in calls[0]
|
||||
assert any(a.startswith("sink_name=hermes_meet_sink") for a in calls[0])
|
||||
assert calls[1][0] == "pactl" and calls[1][1] == "load-module"
|
||||
assert "module-virtual-source" in calls[1]
|
||||
assert any(a.startswith("source_name=hermes_meet_src") for a in calls[1])
|
||||
assert any("master=hermes_meet_sink.monitor" in a for a in calls[1])
|
||||
|
||||
# Dict shape.
|
||||
assert info["platform"] == "linux"
|
||||
assert info["device_name"] == "hermes_meet_src"
|
||||
assert info["write_target"] == "hermes_meet_sink"
|
||||
assert info["sample_rate"] == 48000
|
||||
assert info["channels"] == 2
|
||||
assert info["module_ids"] == [42, 43]
|
||||
|
||||
# Properties.
|
||||
assert br.device_name == "hermes_meet_src"
|
||||
assert br.write_target == "hermes_meet_sink"
|
||||
|
||||
|
||||
def test_teardown_linux_unloads_modules_in_reverse_order():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
def _setup_run(argv, **kwargs):
|
||||
if "module-null-sink" in argv:
|
||||
return _linux_pactl_result("42\n")
|
||||
return _linux_pactl_result("43\n")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_setup_run):
|
||||
br = AudioBridge()
|
||||
br.setup()
|
||||
|
||||
unload_calls: list[list[str]] = []
|
||||
|
||||
def _teardown_run(argv, **kwargs):
|
||||
unload_calls.append(list(argv))
|
||||
return _linux_pactl_result("")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_teardown_run):
|
||||
br.teardown()
|
||||
|
||||
# Two unload calls, in reverse order: 43 (virtual-source) then 42 (sink).
|
||||
assert [c[1] for c in unload_calls] == ["unload-module", "unload-module"]
|
||||
assert unload_calls[0][2] == "43"
|
||||
assert unload_calls[1][2] == "42"
|
||||
|
||||
# Second teardown is a no-op.
|
||||
with patch("plugins.google_meet.audio_bridge.subprocess.run") as run_mock:
|
||||
br.teardown()
|
||||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_linux_parses_module_id_from_multi_line_output():
|
||||
"""Some pactl builds include trailing whitespace / notices."""
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
if "module-null-sink" in argv:
|
||||
return _linux_pactl_result("42 \n")
|
||||
return _linux_pactl_result("43\n")
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=_fake_run):
|
||||
br = AudioBridge()
|
||||
info = br.setup()
|
||||
|
||||
assert info["module_ids"] == [42, 43]
|
||||
|
||||
|
||||
def test_setup_linux_pactl_missing_raises_clean_error():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.run",
|
||||
side_effect=FileNotFoundError("pactl")):
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError, match="pactl"):
|
||||
br.setup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# macOS setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BH_PRESENT = (
|
||||
"Audio:\n"
|
||||
" Devices:\n"
|
||||
" BlackHole 2ch:\n"
|
||||
" Manufacturer: Existential Audio\n"
|
||||
)
|
||||
|
||||
_BH_ABSENT = (
|
||||
"Audio:\n"
|
||||
" Devices:\n"
|
||||
" MacBook Pro Microphone:\n"
|
||||
" Default Input: Yes\n"
|
||||
)
|
||||
|
||||
|
||||
def test_setup_darwin_returns_blackhole_when_present():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Darwin"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.check_output",
|
||||
return_value=_BH_PRESENT) as check:
|
||||
br = AudioBridge()
|
||||
info = br.setup()
|
||||
|
||||
check.assert_called_once()
|
||||
argv = check.call_args.args[0]
|
||||
assert argv[0] == "system_profiler"
|
||||
assert "SPAudioDataType" in argv
|
||||
|
||||
assert info["platform"] == "darwin"
|
||||
assert info["device_name"] == "BlackHole 2ch"
|
||||
assert info["write_target"] == "BlackHole 2ch"
|
||||
assert info["module_ids"] == []
|
||||
assert info["sample_rate"] == 48000
|
||||
assert info["channels"] == 2
|
||||
|
||||
# teardown is a no-op on darwin (no modules to unload).
|
||||
with patch("plugins.google_meet.audio_bridge.subprocess.run") as run_mock:
|
||||
br.teardown()
|
||||
run_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_darwin_raises_when_blackhole_missing():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Darwin"), \
|
||||
patch("plugins.google_meet.audio_bridge.subprocess.check_output",
|
||||
return_value=_BH_ABSENT):
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError, match="BlackHole"):
|
||||
br.setup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows / unsupported
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_windows_raises():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Windows"):
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError, match="not supported"):
|
||||
br.setup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chrome_fake_audio_flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chrome_fake_audio_flags_linux():
|
||||
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Linux"):
|
||||
flags = chrome_fake_audio_flags(
|
||||
{"platform": "linux", "device_name": "hermes_meet_src"}
|
||||
)
|
||||
assert "--use-fake-ui-for-media-stream" in flags
|
||||
|
||||
|
||||
def test_chrome_fake_audio_flags_darwin():
|
||||
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Darwin"):
|
||||
flags = chrome_fake_audio_flags(
|
||||
{"platform": "darwin", "device_name": "BlackHole 2ch"}
|
||||
)
|
||||
assert "--use-fake-ui-for-media-stream" in flags
|
||||
|
||||
|
||||
def test_chrome_fake_audio_flags_windows_raises():
|
||||
from plugins.google_meet.audio_bridge import chrome_fake_audio_flags
|
||||
|
||||
with patch("plugins.google_meet.audio_bridge.platform.system",
|
||||
return_value="Windows"):
|
||||
with pytest.raises(RuntimeError):
|
||||
chrome_fake_audio_flags({"platform": "windows"})
|
||||
|
||||
|
||||
def test_property_access_before_setup_raises():
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
|
||||
br = AudioBridge()
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = br.device_name
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = br.write_target
|
||||
@@ -0,0 +1,674 @@
|
||||
"""Tests for the google_meet node primitive.
|
||||
|
||||
Covers protocol helpers, the file-backed registry, the server's
|
||||
token-and-dispatch machinery, a mocked client, and the CLI plumbing.
|
||||
We never open a real socket — websockets.serve / websockets.sync.client
|
||||
are fully mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_home(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
yield hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# protocol.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_protocol_encode_decode_roundtrip():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
msg = protocol.make_request("ping", "tok", {"x": 1}, req_id="abc")
|
||||
raw = protocol.encode(msg)
|
||||
out = protocol.decode(raw)
|
||||
assert out == msg
|
||||
assert out["type"] == "ping"
|
||||
assert out["id"] == "abc"
|
||||
assert out["token"] == "tok"
|
||||
assert out["payload"] == {"x": 1}
|
||||
|
||||
|
||||
def test_protocol_make_request_autogenerates_id():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
a = protocol.make_request("ping", "tok", {})
|
||||
b = protocol.make_request("ping", "tok", {})
|
||||
assert a["id"] != b["id"]
|
||||
assert len(a["id"]) >= 16 # uuid4 hex
|
||||
|
||||
|
||||
def test_protocol_make_request_rejects_bad_input():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
protocol.make_request("", "tok", {})
|
||||
with pytest.raises(ValueError):
|
||||
protocol.make_request("unknown_type", "tok", {})
|
||||
with pytest.raises(ValueError):
|
||||
protocol.make_request("ping", "tok", "not a dict") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_protocol_decode_raises_on_malformed():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
protocol.decode("not json at all")
|
||||
with pytest.raises(ValueError):
|
||||
protocol.decode("[]") # list, not object
|
||||
with pytest.raises(ValueError):
|
||||
protocol.decode(json.dumps({"id": "x"})) # missing type
|
||||
with pytest.raises(ValueError):
|
||||
protocol.decode(json.dumps({"type": "ping"})) # missing id
|
||||
|
||||
|
||||
def test_protocol_validate_request_happy_path():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
msg = protocol.make_request("status", "secret", {})
|
||||
ok, reason = protocol.validate_request(msg, "secret")
|
||||
assert ok is True
|
||||
assert reason == ""
|
||||
|
||||
|
||||
def test_protocol_validate_request_rejects_bad_token():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
msg = protocol.make_request("status", "wrong", {})
|
||||
ok, reason = protocol.validate_request(msg, "right")
|
||||
assert ok is False
|
||||
assert "token" in reason.lower()
|
||||
|
||||
|
||||
def test_protocol_validate_request_rejects_unknown_type():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
raw = {"type": "nope", "id": "1", "token": "t", "payload": {}}
|
||||
ok, reason = protocol.validate_request(raw, "t")
|
||||
assert ok is False
|
||||
assert "unknown" in reason.lower()
|
||||
|
||||
|
||||
def test_protocol_validate_request_rejects_missing_id():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
raw = {"type": "ping", "token": "t", "payload": {}}
|
||||
ok, reason = protocol.validate_request(raw, "t")
|
||||
assert ok is False
|
||||
assert "id" in reason.lower()
|
||||
|
||||
|
||||
def test_protocol_validate_request_rejects_non_dict_payload():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
raw = {"type": "ping", "id": "1", "token": "t", "payload": "oops"}
|
||||
ok, reason = protocol.validate_request(raw, "t")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_protocol_error_envelope_shape():
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
err = protocol.make_error("abc", "nope")
|
||||
assert err == {"type": "error", "id": "abc", "error": "nope"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registry.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_registry_add_get_roundtrip_persists(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
p = tmp_path / "nodes.json"
|
||||
r = NodeRegistry(path=p)
|
||||
r.add("mac", "ws://mac.local:18789", "deadbeef")
|
||||
|
||||
# Second instance sees it.
|
||||
r2 = NodeRegistry(path=p)
|
||||
entry = r2.get("mac")
|
||||
assert entry is not None
|
||||
assert entry["name"] == "mac"
|
||||
assert entry["url"] == "ws://mac.local:18789"
|
||||
assert entry["token"] == "deadbeef"
|
||||
assert "added_at" in entry
|
||||
|
||||
|
||||
def test_registry_get_returns_none_when_missing(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
assert r.get("ghost") is None
|
||||
|
||||
|
||||
def test_registry_remove(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
r.add("a", "ws://a", "t")
|
||||
assert r.remove("a") is True
|
||||
assert r.get("a") is None
|
||||
assert r.remove("a") is False # idempotent
|
||||
|
||||
|
||||
def test_registry_list_all_sorted(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
r.add("zeta", "ws://z", "t1")
|
||||
r.add("alpha", "ws://a", "t2")
|
||||
names = [n["name"] for n in r.list_all()]
|
||||
assert names == ["alpha", "zeta"]
|
||||
|
||||
|
||||
def test_registry_resolve_auto_picks_single(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
r.add("mac", "ws://mac", "t")
|
||||
picked = r.resolve(None)
|
||||
assert picked is not None
|
||||
assert picked["name"] == "mac"
|
||||
|
||||
|
||||
def test_registry_resolve_ambiguous_returns_none(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
r.add("a", "ws://a", "t")
|
||||
r.add("b", "ws://b", "t")
|
||||
assert r.resolve(None) is None
|
||||
|
||||
|
||||
def test_registry_resolve_empty_returns_none(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
assert r.resolve(None) is None
|
||||
|
||||
|
||||
def test_registry_resolve_by_name(tmp_path):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
r = NodeRegistry(path=tmp_path / "n.json")
|
||||
r.add("a", "ws://a", "t")
|
||||
r.add("b", "ws://b", "t")
|
||||
picked = r.resolve("b")
|
||||
assert picked is not None
|
||||
assert picked["name"] == "b"
|
||||
assert r.resolve("ghost") is None
|
||||
|
||||
|
||||
def test_registry_defaults_to_hermes_home(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
# _isolate_home already set HERMES_HOME to tmp_path/.hermes; the
|
||||
# registry default path must live inside that tree.
|
||||
r = NodeRegistry()
|
||||
r.add("x", "ws://x", "t")
|
||||
expected = Path(tmp_path) / ".hermes" / "workspace" / "meetings" / "nodes.json"
|
||||
assert expected.is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# server.py — token + dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_server_ensure_token_generates_and_persists(tmp_path):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
|
||||
p = tmp_path / "tok.json"
|
||||
s1 = NodeServer(token_path=p)
|
||||
t1 = s1.ensure_token()
|
||||
assert isinstance(t1, str) and len(t1) == 32
|
||||
|
||||
# Reuse on a fresh instance.
|
||||
s2 = NodeServer(token_path=p)
|
||||
t2 = s2.ensure_token()
|
||||
assert t1 == t2
|
||||
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
assert data["token"] == t1
|
||||
assert "generated_at" in data
|
||||
|
||||
|
||||
def test_server_get_token_is_idempotent(tmp_path):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
assert s.get_token() == s.get_token()
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
|
||||
|
||||
|
||||
def test_server_handle_request_rejects_bad_token(tmp_path):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
s.ensure_token()
|
||||
bad = protocol.make_request("ping", "not-the-token", {})
|
||||
resp = asyncio.run(s._handle_request(bad))
|
||||
assert resp["type"] == "error"
|
||||
assert "token" in resp["error"].lower()
|
||||
|
||||
|
||||
def test_server_handle_request_ping(tmp_path):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json", display_name="node-x")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("ping", tok, {})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "pong"
|
||||
assert resp["id"] == req["id"]
|
||||
assert resp["payload"]["display_name"] == "node-x"
|
||||
|
||||
|
||||
def test_server_handle_request_status_dispatches_to_pm(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
monkeypatch.setattr(pm, "status",
|
||||
lambda: {"ok": True, "alive": True, "meetingId": "abc"})
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("status", tok, {})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "response"
|
||||
assert resp["id"] == req["id"]
|
||||
assert resp["payload"] == {"ok": True, "alive": True, "meetingId": "abc"}
|
||||
|
||||
|
||||
def test_server_handle_request_start_bot_dispatches(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_start(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"ok": True, "pid": 42, "meeting_id": "abc-defg-hij"}
|
||||
|
||||
monkeypatch.setattr(pm, "start", fake_start)
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("start_bot", tok, {
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"guest_name": "Bot",
|
||||
"duration": "30m",
|
||||
})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "response"
|
||||
assert resp["payload"]["ok"] is True
|
||||
assert captured["url"] == "https://meet.google.com/abc-defg-hij"
|
||||
assert captured["guest_name"] == "Bot"
|
||||
assert captured["duration"] == "30m"
|
||||
|
||||
|
||||
def test_server_handle_request_start_bot_missing_url(tmp_path):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("start_bot", tok, {"guest_name": "x"})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "error"
|
||||
assert "url" in resp["error"]
|
||||
|
||||
|
||||
def test_server_handle_request_stop_dispatches(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
got = {}
|
||||
|
||||
def fake_stop(*, reason="requested"):
|
||||
got["reason"] = reason
|
||||
return {"ok": True, "reason": reason}
|
||||
|
||||
monkeypatch.setattr(pm, "stop", fake_stop)
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("stop", tok, {"reason": "user-cancel"})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "response"
|
||||
assert got["reason"] == "user-cancel"
|
||||
|
||||
|
||||
def test_server_handle_request_transcript(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
got = {}
|
||||
|
||||
def fake_transcript(last=None):
|
||||
got["last"] = last
|
||||
return {"ok": True, "lines": ["a", "b"], "total": 2}
|
||||
|
||||
monkeypatch.setattr(pm, "transcript", fake_transcript)
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("transcript", tok, {"last": 5})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "response"
|
||||
assert resp["payload"]["lines"] == ["a", "b"]
|
||||
assert got["last"] == 5
|
||||
|
||||
|
||||
def test_server_handle_request_say_enqueues_when_active(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
out = tmp_path / "meet-out"
|
||||
out.mkdir()
|
||||
monkeypatch.setattr(pm, "_read_active",
|
||||
lambda: {"pid": 1, "meeting_id": "m", "out_dir": str(out)})
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("say", tok, {"text": "hello"})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "response"
|
||||
assert resp["payload"]["ok"] is True
|
||||
assert resp["payload"]["enqueued"] is True
|
||||
q = (out / "say_queue.jsonl").read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(q) == 1
|
||||
assert json.loads(q[0])["text"] == "hello"
|
||||
|
||||
|
||||
def test_server_handle_request_say_without_active_still_ok(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
monkeypatch.setattr(pm, "_read_active", lambda: None)
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("say", tok, {"text": "hi"})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "response"
|
||||
assert resp["payload"]["ok"] is True
|
||||
assert resp["payload"]["enqueued"] is False
|
||||
|
||||
|
||||
def test_server_handle_request_wraps_pm_exceptions(tmp_path, monkeypatch):
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
def boom():
|
||||
raise ValueError("kaboom")
|
||||
|
||||
monkeypatch.setattr(pm, "status", boom)
|
||||
|
||||
s = NodeServer(token_path=tmp_path / "t.json")
|
||||
tok = s.ensure_token()
|
||||
req = protocol.make_request("status", tok, {})
|
||||
resp = asyncio.run(s._handle_request(req))
|
||||
assert resp["type"] == "error"
|
||||
assert "kaboom" in resp["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# client.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeWS:
|
||||
"""Minimal context-manager stand-in for websockets.sync.client.connect."""
|
||||
|
||||
def __init__(self, reply_builder):
|
||||
self._reply_builder = reply_builder
|
||||
self.sent = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def send(self, raw):
|
||||
self.sent.append(raw)
|
||||
|
||||
def recv(self, timeout=None):
|
||||
return self._reply_builder(self.sent[-1])
|
||||
|
||||
|
||||
def _install_fake_ws(monkeypatch, reply_builder):
|
||||
fake_ws_holder = {}
|
||||
|
||||
def _connect(url, **kwargs):
|
||||
ws = _FakeWS(reply_builder)
|
||||
fake_ws_holder["ws"] = ws
|
||||
fake_ws_holder["url"] = url
|
||||
fake_ws_holder["kwargs"] = kwargs
|
||||
return ws
|
||||
|
||||
# Patch the concrete import site inside client._rpc
|
||||
import websockets.sync.client as wsc # type: ignore
|
||||
monkeypatch.setattr(wsc, "connect", _connect)
|
||||
return fake_ws_holder
|
||||
|
||||
|
||||
def test_client_rpc_sends_correct_envelope_and_parses_response(monkeypatch):
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
def reply(raw_out):
|
||||
req = protocol.decode(raw_out)
|
||||
return protocol.encode(protocol.make_response(req["id"], {"ok": True, "echo": req["type"]}))
|
||||
|
||||
holder = _install_fake_ws(monkeypatch, reply)
|
||||
|
||||
c = NodeClient("ws://remote:1", "tok123")
|
||||
out = c._rpc("ping", {"hello": 1})
|
||||
assert out == {"ok": True, "echo": "ping"}
|
||||
|
||||
sent = json.loads(holder["ws"].sent[0])
|
||||
assert sent["type"] == "ping"
|
||||
assert sent["token"] == "tok123"
|
||||
assert sent["payload"] == {"hello": 1}
|
||||
assert sent["id"] # non-empty
|
||||
assert holder["url"] == "ws://remote:1"
|
||||
|
||||
|
||||
def test_client_rpc_raises_on_error_envelope(monkeypatch):
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
def reply(raw_out):
|
||||
req = protocol.decode(raw_out)
|
||||
return protocol.encode(protocol.make_error(req["id"], "nope"))
|
||||
|
||||
_install_fake_ws(monkeypatch, reply)
|
||||
|
||||
c = NodeClient("ws://x", "t")
|
||||
with pytest.raises(RuntimeError, match="nope"):
|
||||
c._rpc("ping", {})
|
||||
|
||||
|
||||
def test_client_rpc_raises_on_id_mismatch(monkeypatch):
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
def reply(raw_out):
|
||||
return protocol.encode(protocol.make_response("different-id", {"ok": True}))
|
||||
|
||||
_install_fake_ws(monkeypatch, reply)
|
||||
|
||||
c = NodeClient("ws://x", "t")
|
||||
with pytest.raises(RuntimeError, match="mismatch"):
|
||||
c._rpc("ping", {})
|
||||
|
||||
|
||||
def test_client_convenience_methods_hit_correct_types(monkeypatch):
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node import protocol
|
||||
|
||||
seen = []
|
||||
|
||||
def reply(raw_out):
|
||||
req = protocol.decode(raw_out)
|
||||
seen.append((req["type"], req["payload"]))
|
||||
return protocol.encode(protocol.make_response(req["id"], {"ok": True}))
|
||||
|
||||
_install_fake_ws(monkeypatch, reply)
|
||||
|
||||
c = NodeClient("ws://x", "t")
|
||||
c.start_bot("https://meet.google.com/a-b-c", guest_name="G", duration="10m")
|
||||
c.stop()
|
||||
c.status()
|
||||
c.transcript(last=3)
|
||||
c.say("hi")
|
||||
c.ping()
|
||||
|
||||
types = [t for t, _ in seen]
|
||||
assert types == ["start_bot", "stop", "status", "transcript", "say", "ping"]
|
||||
# Check specific payload routing
|
||||
assert seen[0][1]["url"] == "https://meet.google.com/a-b-c"
|
||||
assert seen[0][1]["guest_name"] == "G"
|
||||
assert seen[0][1]["duration"] == "10m"
|
||||
assert seen[3][1]["last"] == 3
|
||||
assert seen[4][1]["text"] == "hi"
|
||||
|
||||
|
||||
def test_client_init_rejects_bad_args():
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
NodeClient("", "t")
|
||||
with pytest.raises(ValueError):
|
||||
NodeClient("ws://x", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cli.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_parser():
|
||||
from plugins.google_meet.node.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="meet-node-test")
|
||||
register_cli(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def test_cli_approve_list_remove(capsys):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
p = _build_parser()
|
||||
|
||||
args = p.parse_args(["approve", "mac", "ws://mac:1", "tok"])
|
||||
rc = args.func(args)
|
||||
assert rc == 0
|
||||
assert NodeRegistry().get("mac") is not None
|
||||
|
||||
args = p.parse_args(["list"])
|
||||
rc = args.func(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "mac" in out
|
||||
assert "ws://mac:1" in out
|
||||
|
||||
args = p.parse_args(["remove", "mac"])
|
||||
rc = args.func(args)
|
||||
assert rc == 0
|
||||
assert NodeRegistry().get("mac") is None
|
||||
|
||||
|
||||
def test_cli_list_empty(capsys):
|
||||
p = _build_parser()
|
||||
args = p.parse_args(["list"])
|
||||
rc = args.func(args)
|
||||
assert rc == 0
|
||||
assert "no nodes" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cli_remove_missing_returns_nonzero():
|
||||
p = _build_parser()
|
||||
args = p.parse_args(["remove", "ghost"])
|
||||
rc = args.func(args)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_cli_status_pings_via_node_client(capsys, monkeypatch):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node import cli as node_cli
|
||||
|
||||
NodeRegistry().add("mac", "ws://mac:1", "tok")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, url, token):
|
||||
assert url == "ws://mac:1"
|
||||
assert token == "tok"
|
||||
|
||||
def ping(self):
|
||||
return {"type": "pong", "display_name": "hermes-meet-node"}
|
||||
|
||||
monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
|
||||
|
||||
p = _build_parser()
|
||||
args = p.parse_args(["status", "mac"])
|
||||
rc = args.func(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out.strip()
|
||||
data = json.loads(out)
|
||||
assert data["ok"] is True
|
||||
assert data["node"] == "mac"
|
||||
|
||||
|
||||
def test_cli_status_unknown_node_fails(capsys):
|
||||
p = _build_parser()
|
||||
args = p.parse_args(["status", "ghost"])
|
||||
rc = args.func(args)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_cli_status_reports_client_error(capsys, monkeypatch):
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node import cli as node_cli
|
||||
|
||||
NodeRegistry().add("mac", "ws://mac:1", "tok")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, url, token):
|
||||
pass
|
||||
|
||||
def ping(self):
|
||||
raise RuntimeError("connection refused")
|
||||
|
||||
monkeypatch.setattr(node_cli, "NodeClient", _FakeClient)
|
||||
|
||||
p = _build_parser()
|
||||
args = p.parse_args(["status", "mac"])
|
||||
rc = args.func(args)
|
||||
assert rc == 1
|
||||
data = json.loads(capsys.readouterr().out.strip())
|
||||
assert data["ok"] is False
|
||||
assert "connection refused" in data["error"]
|
||||
@@ -0,0 +1,815 @@
|
||||
"""Tests for the google_meet plugin.
|
||||
|
||||
Covers the safety-gated pieces that don't require Playwright:
|
||||
|
||||
* URL regex — only ``https://meet.google.com/`` URLs pass
|
||||
* Meeting-id extraction from Meet URLs
|
||||
* Status / transcript writes round-trip through the file-backed state
|
||||
* Tool handlers return well-formed JSON under all branches
|
||||
* Process manager refuses unsafe URLs and clears stale state cleanly
|
||||
* ``_on_session_end`` hook is defensive (no-ops when no bot active)
|
||||
|
||||
Does NOT spawn a real Chromium — we mock ``subprocess.Popen`` where needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_home(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
yield hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL safety gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_safe_meet_url_accepts_standard_meet_codes():
|
||||
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
||||
|
||||
assert _is_safe_meet_url("https://meet.google.com/abc-defg-hij")
|
||||
assert _is_safe_meet_url("https://meet.google.com/abc-defg-hij?pli=1")
|
||||
assert _is_safe_meet_url("https://meet.google.com/new")
|
||||
assert _is_safe_meet_url("https://meet.google.com/lookup/ABC123")
|
||||
|
||||
|
||||
def test_is_safe_meet_url_rejects_non_meet_urls():
|
||||
from plugins.google_meet.meet_bot import _is_safe_meet_url
|
||||
|
||||
# wrong host
|
||||
assert not _is_safe_meet_url("https://evil.example.com/abc-defg-hij")
|
||||
# wrong scheme
|
||||
assert not _is_safe_meet_url("http://meet.google.com/abc-defg-hij")
|
||||
# malformed code
|
||||
assert not _is_safe_meet_url("https://meet.google.com/not-a-meet-code")
|
||||
# subdomain hijack attempts
|
||||
assert not _is_safe_meet_url("https://meet.google.com.evil.com/abc-defg-hij")
|
||||
assert not _is_safe_meet_url("https://notmeet.google.com/abc-defg-hij")
|
||||
# empty / wrong type
|
||||
assert not _is_safe_meet_url("")
|
||||
assert not _is_safe_meet_url(None) # type: ignore[arg-type]
|
||||
assert not _is_safe_meet_url(123) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_meeting_id_extraction():
|
||||
from plugins.google_meet.meet_bot import _meeting_id_from_url
|
||||
|
||||
assert _meeting_id_from_url("https://meet.google.com/abc-defg-hij") == "abc-defg-hij"
|
||||
assert _meeting_id_from_url("https://meet.google.com/abc-defg-hij?pli=1") == "abc-defg-hij"
|
||||
# fallback for codes we can't parse (e.g. /new before redirect)
|
||||
fallback = _meeting_id_from_url("https://meet.google.com/new")
|
||||
assert fallback.startswith("meet-")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _BotState — transcript + status file round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_bot_state_dedupes_captions_and_flushes_status(tmp_path):
|
||||
from plugins.google_meet.meet_bot import _BotState
|
||||
|
||||
out = tmp_path / "session"
|
||||
state = _BotState(out_dir=out, meeting_id="abc-defg-hij",
|
||||
url="https://meet.google.com/abc-defg-hij")
|
||||
|
||||
state.record_caption("Alice", "Hey everyone")
|
||||
state.record_caption("Alice", "Hey everyone") # dup — ignored
|
||||
state.record_caption("Bob", "Let's start")
|
||||
|
||||
transcript = (out / "transcript.txt").read_text()
|
||||
assert "Alice: Hey everyone" in transcript
|
||||
assert "Bob: Let's start" in transcript
|
||||
# dedup — Alice line appears exactly once
|
||||
assert transcript.count("Alice: Hey everyone") == 1
|
||||
|
||||
status = json.loads((out / "status.json").read_text())
|
||||
assert status["meetingId"] == "abc-defg-hij"
|
||||
assert status["transcriptLines"] == 2
|
||||
assert status["transcriptPath"].endswith("transcript.txt")
|
||||
|
||||
|
||||
def test_bot_state_ignores_blank_text(tmp_path):
|
||||
from plugins.google_meet.meet_bot import _BotState
|
||||
|
||||
state = _BotState(out_dir=tmp_path / "s", meeting_id="x-y-z",
|
||||
url="https://meet.google.com/x-y-z")
|
||||
state.record_caption("Alice", "")
|
||||
state.record_caption("Alice", " ")
|
||||
state.record_caption("", "text but no speaker")
|
||||
|
||||
status = json.loads((tmp_path / "s" / "status.json").read_text())
|
||||
assert status["transcriptLines"] == 1
|
||||
# blank-speaker falls back to "Unknown"
|
||||
assert "Unknown: text but no speaker" in (tmp_path / "s" / "transcript.txt").read_text()
|
||||
|
||||
|
||||
def test_parse_duration():
|
||||
from plugins.google_meet.meet_bot import _parse_duration
|
||||
|
||||
assert _parse_duration("30m") == 30 * 60
|
||||
assert _parse_duration("2h") == 2 * 3600
|
||||
assert _parse_duration("90s") == 90
|
||||
assert _parse_duration("90") == 90
|
||||
assert _parse_duration("") is None
|
||||
assert _parse_duration("bogus") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_manager — refuses unsafe URLs, manages active pointer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_start_refuses_unsafe_url():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
res = pm.start("https://evil.example.com/abc-defg-hij")
|
||||
assert res["ok"] is False
|
||||
assert "refusing" in res["error"]
|
||||
|
||||
|
||||
def test_status_reports_no_active_meeting():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
assert pm.status() == {"ok": False, "reason": "no active meeting"}
|
||||
assert pm.transcript() == {"ok": False, "reason": "no active meeting"}
|
||||
assert pm.stop() == {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
|
||||
def test_start_spawns_subprocess_and_writes_active_pointer(tmp_path):
|
||||
"""Verify start() wires env vars correctly and records the pid."""
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
|
||||
captured_env = {}
|
||||
captured_argv = []
|
||||
|
||||
def _fake_popen(argv, **kwargs):
|
||||
captured_argv.extend(argv)
|
||||
captured_env.update(kwargs.get("env") or {})
|
||||
return _FakeProc(99999)
|
||||
|
||||
with patch.object(pm.subprocess, "Popen", side_effect=_fake_popen):
|
||||
# Also prevent pid liveness probe from stomping on our real pids
|
||||
with patch.object(pm, "_pid_alive", return_value=False):
|
||||
res = pm.start(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
guest_name="Test Bot",
|
||||
duration="15m",
|
||||
)
|
||||
|
||||
assert res["ok"] is True
|
||||
assert res["meeting_id"] == "abc-defg-hij"
|
||||
assert res["pid"] == 99999
|
||||
assert captured_env["HERMES_MEET_URL"] == "https://meet.google.com/abc-defg-hij"
|
||||
assert captured_env["HERMES_MEET_GUEST_NAME"] == "Test Bot"
|
||||
assert captured_env["HERMES_MEET_DURATION"] == "15m"
|
||||
# python -m plugins.google_meet.meet_bot
|
||||
assert any("plugins.google_meet.meet_bot" in a for a in captured_argv)
|
||||
|
||||
# .active.json points at the bot
|
||||
active = pm._read_active()
|
||||
assert active is not None
|
||||
assert active["pid"] == 99999
|
||||
assert active["meeting_id"] == "abc-defg-hij"
|
||||
|
||||
|
||||
def test_transcript_reads_last_n_lines(tmp_path):
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
meeting_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
|
||||
meeting_dir.mkdir(parents=True)
|
||||
(meeting_dir / "transcript.txt").write_text(
|
||||
"[10:00:00] Alice: one\n"
|
||||
"[10:00:01] Bob: two\n"
|
||||
"[10:00:02] Alice: three\n"
|
||||
)
|
||||
pm._write_active({
|
||||
"pid": 0, "meeting_id": "abc-defg-hij",
|
||||
"out_dir": str(meeting_dir),
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"started_at": 0,
|
||||
})
|
||||
|
||||
res = pm.transcript(last=2)
|
||||
assert res["ok"] is True
|
||||
assert res["total"] == 3
|
||||
assert len(res["lines"]) == 2
|
||||
assert res["lines"][-1].endswith("Alice: three")
|
||||
|
||||
|
||||
def test_stop_signals_process_and_clears_pointer(tmp_path):
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
pm._write_active({
|
||||
"pid": 11111, "meeting_id": "x-y-z",
|
||||
"out_dir": str(tmp_path / "x-y-z"),
|
||||
"url": "https://meet.google.com/x-y-z",
|
||||
"started_at": 0,
|
||||
})
|
||||
|
||||
alive_seq = iter([True, True, False]) # alive at first, gone after SIGTERM
|
||||
def _alive(pid):
|
||||
try:
|
||||
return next(alive_seq)
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
sent = []
|
||||
def _kill(pid, sig):
|
||||
sent.append((pid, sig))
|
||||
|
||||
with patch.object(pm, "_pid_alive", side_effect=_alive), \
|
||||
patch.object(pm.os, "kill", side_effect=_kill), \
|
||||
patch.object(pm.time, "sleep", lambda _s: None):
|
||||
res = pm.stop()
|
||||
|
||||
assert res["ok"] is True
|
||||
assert (11111, signal.SIGTERM) in sent
|
||||
# .active.json cleared
|
||||
assert pm._read_active() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers — JSON shape + safety gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_meet_join_handler_missing_url_returns_error():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
out = json.loads(handle_meet_join({}))
|
||||
assert out["success"] is False
|
||||
assert "url is required" in out["error"]
|
||||
|
||||
|
||||
def test_meet_join_handler_respects_safety_gate():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=True):
|
||||
out = json.loads(handle_meet_join({"url": "https://evil.example.com/foo"}))
|
||||
assert out["success"] is False
|
||||
assert "refusing" in out["error"]
|
||||
|
||||
|
||||
def test_meet_join_handler_returns_error_when_playwright_missing():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=False):
|
||||
out = json.loads(handle_meet_join({"url": "https://meet.google.com/abc-defg-hij"}))
|
||||
assert out["success"] is False
|
||||
assert "prerequisites missing" in out["error"]
|
||||
|
||||
|
||||
def test_meet_say_requires_text():
|
||||
from plugins.google_meet.tools import handle_meet_say
|
||||
|
||||
out = json.loads(handle_meet_say({}))
|
||||
assert out["success"] is False
|
||||
assert "text is required" in out["error"]
|
||||
|
||||
|
||||
def test_meet_say_no_active_meeting():
|
||||
from plugins.google_meet.tools import handle_meet_say
|
||||
|
||||
out = json.loads(handle_meet_say({"text": "hello everyone"}))
|
||||
assert out["success"] is False
|
||||
# Falls through to pm.enqueue_say which reports no active meeting.
|
||||
assert "no active meeting" in out.get("reason", "")
|
||||
|
||||
|
||||
def test_meet_status_and_transcript_no_active():
|
||||
from plugins.google_meet.tools import handle_meet_status, handle_meet_transcript
|
||||
|
||||
assert json.loads(handle_meet_status({}))["success"] is False
|
||||
assert json.loads(handle_meet_transcript({}))["success"] is False
|
||||
|
||||
|
||||
def test_meet_leave_no_active():
|
||||
from plugins.google_meet.tools import handle_meet_leave
|
||||
|
||||
out = json.loads(handle_meet_leave({}))
|
||||
assert out["success"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _on_session_end — defensive cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_on_session_end_noop_when_nothing_active():
|
||||
from plugins.google_meet import _on_session_end
|
||||
# Should not raise and should not call stop().
|
||||
with patch("plugins.google_meet.pm.stop") as stop_mock:
|
||||
_on_session_end()
|
||||
stop_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_on_session_end_stops_live_bot():
|
||||
from plugins.google_meet import _on_session_end
|
||||
from plugins.google_meet import pm
|
||||
|
||||
with patch.object(pm, "status", return_value={"ok": True, "alive": True}), \
|
||||
patch.object(pm, "stop") as stop_mock:
|
||||
_on_session_end()
|
||||
stop_mock.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin register() — platform gating + tool registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_register_refuses_on_windows():
|
||||
import plugins.google_meet as plugin
|
||||
|
||||
calls = {"tools": [], "cli": [], "hooks": []}
|
||||
|
||||
class _Ctx:
|
||||
def register_tool(self, **kw): calls["tools"].append(kw["name"])
|
||||
def register_cli_command(self, **kw): calls["cli"].append(kw["name"])
|
||||
def register_hook(self, name, fn): calls["hooks"].append(name)
|
||||
|
||||
with patch.object(plugin.platform, "system", return_value="Windows"):
|
||||
plugin.register(_Ctx())
|
||||
|
||||
assert calls == {"tools": [], "cli": [], "hooks": []}
|
||||
|
||||
|
||||
def test_register_wires_tools_cli_and_hook_on_linux():
|
||||
import plugins.google_meet as plugin
|
||||
|
||||
calls = {"tools": [], "cli": [], "hooks": []}
|
||||
|
||||
class _Ctx:
|
||||
def register_tool(self, **kw): calls["tools"].append(kw["name"])
|
||||
def register_cli_command(self, **kw): calls["cli"].append(kw["name"])
|
||||
def register_hook(self, name, fn): calls["hooks"].append(name)
|
||||
|
||||
with patch.object(plugin.platform, "system", return_value="Linux"):
|
||||
plugin.register(_Ctx())
|
||||
|
||||
assert set(calls["tools"]) == {
|
||||
"meet_join", "meet_status", "meet_transcript", "meet_leave", "meet_say",
|
||||
}
|
||||
assert calls["cli"] == ["meet"]
|
||||
assert calls["hooks"] == ["on_session_end"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2: process_manager.enqueue_say + realtime-mode passthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_enqueue_say_requires_text():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
assert pm.enqueue_say("")["ok"] is False
|
||||
assert pm.enqueue_say(" ")["ok"] is False
|
||||
|
||||
|
||||
def test_enqueue_say_no_active_meeting():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
res = pm.enqueue_say("hi team")
|
||||
assert res["ok"] is False
|
||||
assert "no active meeting" in res["reason"]
|
||||
|
||||
|
||||
def test_enqueue_say_rejects_transcribe_mode(tmp_path):
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
out_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
|
||||
out_dir.mkdir(parents=True)
|
||||
pm._write_active({
|
||||
"pid": 0, "meeting_id": "abc-defg-hij",
|
||||
"out_dir": str(out_dir), "url": "https://meet.google.com/abc-defg-hij",
|
||||
"started_at": 0, "mode": "transcribe",
|
||||
})
|
||||
res = pm.enqueue_say("hi team")
|
||||
assert res["ok"] is False
|
||||
assert "transcribe mode" in res["reason"]
|
||||
|
||||
|
||||
def test_enqueue_say_writes_jsonl_in_realtime_mode():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
out_dir = Path(os.environ["HERMES_HOME"]) / "workspace" / "meetings" / "abc-defg-hij"
|
||||
out_dir.mkdir(parents=True)
|
||||
pm._write_active({
|
||||
"pid": 0, "meeting_id": "abc-defg-hij",
|
||||
"out_dir": str(out_dir), "url": "https://meet.google.com/abc-defg-hij",
|
||||
"started_at": 0, "mode": "realtime",
|
||||
})
|
||||
res = pm.enqueue_say("hello everyone")
|
||||
assert res["ok"] is True
|
||||
assert "enqueued_id" in res
|
||||
|
||||
queue = out_dir / "say_queue.jsonl"
|
||||
assert queue.is_file()
|
||||
lines = [json.loads(ln) for ln in queue.read_text().splitlines() if ln.strip()]
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["text"] == "hello everyone"
|
||||
|
||||
|
||||
def test_start_passes_mode_into_active_record():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid): self.pid = pid
|
||||
|
||||
with patch.object(pm.subprocess, "Popen", return_value=_FakeProc(12345)), \
|
||||
patch.object(pm, "_pid_alive", return_value=False):
|
||||
res = pm.start(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
mode="realtime",
|
||||
)
|
||||
assert res["ok"] is True
|
||||
assert res["mode"] == "realtime"
|
||||
assert pm._read_active()["mode"] == "realtime"
|
||||
|
||||
|
||||
def test_start_realtime_env_vars_threaded_through():
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid): self.pid = pid
|
||||
|
||||
captured_env = {}
|
||||
def _fake_popen(argv, **kwargs):
|
||||
captured_env.update(kwargs.get("env") or {})
|
||||
return _FakeProc(11111)
|
||||
|
||||
with patch.object(pm.subprocess, "Popen", side_effect=_fake_popen), \
|
||||
patch.object(pm, "_pid_alive", return_value=False):
|
||||
pm.start(
|
||||
"https://meet.google.com/abc-defg-hij",
|
||||
mode="realtime",
|
||||
realtime_model="gpt-realtime",
|
||||
realtime_voice="alloy",
|
||||
realtime_instructions="Be brief.",
|
||||
realtime_api_key="sk-test",
|
||||
)
|
||||
assert captured_env["HERMES_MEET_MODE"] == "realtime"
|
||||
assert captured_env["HERMES_MEET_REALTIME_MODEL"] == "gpt-realtime"
|
||||
assert captured_env["HERMES_MEET_REALTIME_VOICE"] == "alloy"
|
||||
assert captured_env["HERMES_MEET_REALTIME_INSTRUCTIONS"] == "Be brief."
|
||||
assert captured_env["HERMES_MEET_REALTIME_KEY"] == "sk-test"
|
||||
|
||||
|
||||
def test_meet_join_accepts_realtime_mode():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
with patch("plugins.google_meet.tools.check_meet_requirements", return_value=True), \
|
||||
patch("plugins.google_meet.tools.pm.start", return_value={"ok": True, "meeting_id": "x-y-z"}) as start_mock:
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"mode": "realtime",
|
||||
}))
|
||||
assert out["success"] is True
|
||||
assert start_mock.call_args.kwargs["mode"] == "realtime"
|
||||
|
||||
|
||||
def test_meet_join_rejects_bad_mode():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"mode": "bogus",
|
||||
}))
|
||||
assert out["success"] is False
|
||||
assert "mode must be" in out["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3: NodeClient routing from tool handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_meet_join_unknown_node_returns_clear_error():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "my-mac",
|
||||
}))
|
||||
assert out["success"] is False
|
||||
assert "no registered meet node" in out["error"]
|
||||
|
||||
|
||||
def test_meet_join_routes_to_registered_node():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("my-mac", "ws://1.2.3.4:18789", "tok")
|
||||
|
||||
with patch("plugins.google_meet.node.client.NodeClient.start_bot",
|
||||
return_value={"ok": True, "meeting_id": "a-b-c"}) as call_mock:
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "my-mac",
|
||||
"mode": "realtime",
|
||||
}))
|
||||
assert out["success"] is True
|
||||
assert out["node"] == "my-mac"
|
||||
assert call_mock.call_args.kwargs["mode"] == "realtime"
|
||||
|
||||
|
||||
def test_meet_say_routes_to_node():
|
||||
from plugins.google_meet.tools import handle_meet_say
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("my-mac", "ws://1.2.3.4:18789", "tok")
|
||||
|
||||
with patch("plugins.google_meet.node.client.NodeClient.say",
|
||||
return_value={"ok": True, "enqueued_id": "abc"}) as call_mock:
|
||||
out = json.loads(handle_meet_say({"text": "hello", "node": "my-mac"}))
|
||||
assert out["success"] is True
|
||||
assert out["node"] == "my-mac"
|
||||
call_mock.assert_called_once_with("hello")
|
||||
|
||||
|
||||
def test_meet_join_auto_node_selects_sole_registered():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("only-one", "ws://1.2.3.4:18789", "tok")
|
||||
|
||||
with patch("plugins.google_meet.node.client.NodeClient.start_bot",
|
||||
return_value={"ok": True}) as call_mock:
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "auto",
|
||||
}))
|
||||
assert out["success"] is True
|
||||
assert out["node"] == "only-one"
|
||||
assert call_mock.called
|
||||
|
||||
|
||||
def test_meet_join_auto_node_ambiguous_returns_error():
|
||||
from plugins.google_meet.tools import handle_meet_join
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
|
||||
reg = NodeRegistry()
|
||||
reg.add("a", "ws://1.2.3.4:18789", "tok")
|
||||
reg.add("b", "ws://5.6.7.8:18789", "tok")
|
||||
|
||||
out = json.loads(handle_meet_join({
|
||||
"url": "https://meet.google.com/abc-defg-hij",
|
||||
"node": "auto",
|
||||
}))
|
||||
assert out["success"] is False
|
||||
assert "no registered meet node" in out["error"]
|
||||
|
||||
|
||||
def test_cli_register_includes_node_subcommand():
|
||||
"""`hermes meet` argparse tree includes the node subtree."""
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
# Parse a known-good node invocation to prove the subtree is wired.
|
||||
ns = parser.parse_args(["node", "list"])
|
||||
assert ns.meet_command == "node"
|
||||
assert ns.node_cmd == "list"
|
||||
|
||||
|
||||
def test_cli_join_accepts_mode_and_node_flags():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args([
|
||||
"join", "https://meet.google.com/abc-defg-hij",
|
||||
"--mode", "realtime", "--node", "my-mac",
|
||||
])
|
||||
assert ns.mode == "realtime"
|
||||
assert ns.node == "my-mac"
|
||||
|
||||
|
||||
def test_cli_say_subcommand_exists():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args(["say", "hello team", "--node", "my-mac"])
|
||||
assert ns.text == "hello team"
|
||||
assert ns.node == "my-mac"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2.1: new _BotState fields + status dict shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_bot_state_exposes_v2_telemetry_fields(tmp_path):
|
||||
from plugins.google_meet.meet_bot import _BotState
|
||||
|
||||
state = _BotState(out_dir=tmp_path / "s", meeting_id="x-y-z",
|
||||
url="https://meet.google.com/x-y-z")
|
||||
# Defaults for the new fields.
|
||||
status = json.loads((tmp_path / "s" / "status.json").read_text())
|
||||
for key in (
|
||||
"realtime", "realtimeReady", "realtimeDevice",
|
||||
"audioBytesOut", "lastAudioOutAt", "lastBargeInAt",
|
||||
"joinAttemptedAt", "leaveReason",
|
||||
):
|
||||
assert key in status, f"missing v2 telemetry key: {key}"
|
||||
assert status["realtime"] is False
|
||||
assert status["realtimeReady"] is False
|
||||
assert status["audioBytesOut"] == 0
|
||||
|
||||
# Setting them flushes them.
|
||||
state.set(realtime=True, realtime_ready=True, audio_bytes_out=1024,
|
||||
leave_reason="lobby_timeout")
|
||||
status = json.loads((tmp_path / "s" / "status.json").read_text())
|
||||
assert status["realtime"] is True
|
||||
assert status["realtimeReady"] is True
|
||||
assert status["audioBytesOut"] == 1024
|
||||
assert status["leaveReason"] == "lobby_timeout"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admission detection + barge-in helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_looks_like_human_speaker():
|
||||
from plugins.google_meet.meet_bot import _looks_like_human_speaker
|
||||
|
||||
# Blank, "unknown", "you", and the bot's own name → not human (no barge-in)
|
||||
for s in ("", " ", "Unknown", "unknown", "You", "you", "Hermes Agent", "hermes agent"):
|
||||
assert not _looks_like_human_speaker(s, "Hermes Agent"), f"{s!r} should NOT be human"
|
||||
# Real names → human (barge-in)
|
||||
for s in ("Alice", "Bob Lee", "@teknium"):
|
||||
assert _looks_like_human_speaker(s, "Hermes Agent"), f"{s!r} SHOULD be human"
|
||||
|
||||
|
||||
def test_detect_admission_returns_false_on_error():
|
||||
from plugins.google_meet.meet_bot import _detect_admission
|
||||
|
||||
class _FakePage:
|
||||
def evaluate(self, _js): raise RuntimeError("boom")
|
||||
|
||||
assert _detect_admission(_FakePage()) is False
|
||||
|
||||
|
||||
def test_detect_admission_true_when_probe_returns_true():
|
||||
from plugins.google_meet.meet_bot import _detect_admission
|
||||
|
||||
class _FakePage:
|
||||
def evaluate(self, _js): return True
|
||||
|
||||
assert _detect_admission(_FakePage()) is True
|
||||
|
||||
|
||||
def test_detect_denied_returns_false_on_error():
|
||||
from plugins.google_meet.meet_bot import _detect_denied
|
||||
|
||||
class _FakePage:
|
||||
def evaluate(self, _js): raise RuntimeError("boom")
|
||||
|
||||
assert _detect_denied(_FakePage()) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Realtime session counters + cancel_response (barge-in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_realtime_session_cancel_response_when_disconnected():
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
|
||||
# No _ws yet — cancel should no-op and return False.
|
||||
assert sess.cancel_response() is False
|
||||
|
||||
|
||||
def test_realtime_session_cancel_response_sends_cancel_frame():
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
|
||||
sent = []
|
||||
|
||||
class _FakeWs:
|
||||
def send(self, msg): sent.append(msg)
|
||||
|
||||
sess._ws = _FakeWs()
|
||||
assert sess.cancel_response() is True
|
||||
assert len(sent) == 1
|
||||
import json as _j
|
||||
envelope = _j.loads(sent[0])
|
||||
assert envelope == {"type": "response.cancel"}
|
||||
|
||||
|
||||
def test_realtime_session_counters_initialized():
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=None)
|
||||
assert sess.audio_bytes_out == 0
|
||||
assert sess.last_audio_out_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hermes meet install CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_install_subcommand_is_registered():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args(["install"])
|
||||
assert ns.meet_command == "install"
|
||||
assert ns.realtime is False
|
||||
assert ns.yes is False
|
||||
|
||||
|
||||
def test_cli_install_flags_parse():
|
||||
import argparse
|
||||
from plugins.google_meet.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
|
||||
ns = parser.parse_args(["install", "--realtime", "--yes"])
|
||||
assert ns.realtime is True
|
||||
assert ns.yes is True
|
||||
|
||||
|
||||
def test_cmd_install_refuses_windows(capsys):
|
||||
from plugins.google_meet.cli import _cmd_install
|
||||
|
||||
with patch("plugins.google_meet.cli.platform" if False else "platform.system",
|
||||
return_value="Windows"):
|
||||
rc = _cmd_install(realtime=False, assume_yes=True)
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Windows" in out
|
||||
|
||||
|
||||
def test_cmd_install_runs_pip_and_playwright(capsys):
|
||||
"""End-to-end wiring: pip + playwright install invoked, returncodes handled."""
|
||||
from plugins.google_meet.cli import _cmd_install
|
||||
|
||||
calls = []
|
||||
class _FakeRes:
|
||||
def __init__(self, rc=0): self.returncode = rc
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
return _FakeRes(0)
|
||||
|
||||
with patch("platform.system", return_value="Linux"), \
|
||||
patch("subprocess.run", side_effect=_fake_run), \
|
||||
patch("shutil.which", return_value="/usr/bin/paplay"):
|
||||
rc = _cmd_install(realtime=False, assume_yes=True)
|
||||
assert rc == 0
|
||||
# First invocation: dependency install via the uv→pip ladder
|
||||
# (shutil.which is mocked truthy, so the uv tier is taken: `<uv> pip install ...`)
|
||||
pip_cmds = [
|
||||
c for c in calls
|
||||
if "install" in c and "playwright" in c and "websockets" in c
|
||||
]
|
||||
assert pip_cmds, f"no dependency install run: {calls}"
|
||||
# Second: playwright install chromium
|
||||
pw_cmds = [c for c in calls if len(c) > 2 and c[1:4] == ["-m", "playwright", "install"]]
|
||||
assert pw_cmds, f"no playwright install run: {calls}"
|
||||
assert "chromium" in pw_cmds[0]
|
||||
|
||||
|
||||
def test_cmd_install_realtime_skips_when_deps_present(capsys):
|
||||
"""When paplay + pactl are already on PATH, no sudo call happens."""
|
||||
from plugins.google_meet.cli import _cmd_install
|
||||
|
||||
calls = []
|
||||
class _FakeRes:
|
||||
def __init__(self, rc=0): self.returncode = rc
|
||||
|
||||
def _fake_run(argv, **kwargs):
|
||||
calls.append(list(argv))
|
||||
return _FakeRes(0)
|
||||
|
||||
with patch("platform.system", return_value="Linux"), \
|
||||
patch("subprocess.run", side_effect=_fake_run), \
|
||||
patch("shutil.which", return_value="/usr/bin/paplay"):
|
||||
rc = _cmd_install(realtime=True, assume_yes=True)
|
||||
assert rc == 0
|
||||
# No sudo apt-get call — paplay was already on PATH.
|
||||
sudo_calls = [c for c in calls if c and c[0] == "sudo"]
|
||||
assert sudo_calls == [], f"unexpected sudo invocation: {sudo_calls}"
|
||||
out = capsys.readouterr().out
|
||||
assert "already installed" in out
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Tests for plugins.google_meet.realtime.openai_client (v2).
|
||||
|
||||
Uses a scripted fake WebSocket — no network, no API key required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_home(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
yield hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeWS:
|
||||
"""Scripted WS: send() records frames, recv() pops a queue."""
|
||||
|
||||
def __init__(self, recv_frames: list):
|
||||
self.sent: list[dict] = []
|
||||
self._recv_q: list = list(recv_frames)
|
||||
self.closed = False
|
||||
|
||||
def send(self, payload):
|
||||
# Always accept str payloads — client encodes JSON with json.dumps.
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
payload = payload.decode()
|
||||
self.sent.append(json.loads(payload))
|
||||
|
||||
def recv(self, timeout=None): # noqa: ARG002
|
||||
if not self._recv_q:
|
||||
raise RuntimeError("fake ws: no more frames")
|
||||
frame = self._recv_q.pop(0)
|
||||
if isinstance(frame, dict):
|
||||
return json.dumps(frame)
|
||||
return frame
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _install_fake_websockets(monkeypatch, fake_ws):
|
||||
"""Install a fake ``websockets.sync.client`` module in sys.modules."""
|
||||
mod_websockets = types.ModuleType("websockets")
|
||||
mod_sync = types.ModuleType("websockets.sync")
|
||||
mod_sync_client = types.ModuleType("websockets.sync.client")
|
||||
|
||||
captured = {"url": None, "headers": None, "kwargs": None}
|
||||
|
||||
def _connect(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
captured["headers"] = (
|
||||
kwargs.get("additional_headers") or kwargs.get("extra_headers")
|
||||
)
|
||||
return fake_ws
|
||||
|
||||
mod_sync_client.connect = _connect
|
||||
mod_sync.client = mod_sync_client
|
||||
mod_websockets.sync = mod_sync
|
||||
|
||||
monkeypatch.setitem(sys.modules, "websockets", mod_websockets)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync", mod_sync)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync.client", mod_sync_client)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_sends_session_update_with_voice_and_instructions(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
ws = _FakeWS(recv_frames=[])
|
||||
captured = _install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sess = RealtimeSession(
|
||||
api_key="sk-test",
|
||||
model="gpt-realtime",
|
||||
voice="verse",
|
||||
instructions="Be brief.",
|
||||
)
|
||||
sess.connect()
|
||||
|
||||
# Auth + beta headers set.
|
||||
assert captured["url"].startswith("wss://api.openai.com/v1/realtime")
|
||||
assert "model=gpt-realtime" in captured["url"]
|
||||
headers = captured["headers"] or []
|
||||
hdict = dict(headers)
|
||||
assert hdict.get("Authorization") == "Bearer sk-test"
|
||||
assert hdict.get("OpenAI-Beta") == "realtime=v1"
|
||||
|
||||
# First frame sent must be session.update with the right shape.
|
||||
assert len(ws.sent) == 1
|
||||
update = ws.sent[0]
|
||||
assert update["type"] == "session.update"
|
||||
s = update["session"]
|
||||
assert s["voice"] == "verse"
|
||||
assert s["instructions"] == "Be brief."
|
||||
assert set(s["modalities"]) == {"audio", "text"}
|
||||
assert s["output_audio_format"] == "pcm16"
|
||||
assert s["input_audio_format"] == "pcm16"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# speak()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_speak_sends_create_and_response_and_writes_audio(monkeypatch, tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
audio_bytes = b"\x01\x02\x03\x04PCM!"
|
||||
b64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
recv_frames = [
|
||||
{"type": "response.created"},
|
||||
{"type": "response.audio.delta", "delta": b64},
|
||||
{"type": "response.audio.delta", "delta": base64.b64encode(b"more").decode()},
|
||||
{"type": "response.done"},
|
||||
]
|
||||
ws = _FakeWS(recv_frames=recv_frames)
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sink = tmp_path / "out.pcm"
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=sink)
|
||||
sess.connect()
|
||||
result = sess.speak("Hello everyone.")
|
||||
|
||||
# Frames sent after session.update: conversation.item.create then response.create.
|
||||
types_sent = [f["type"] for f in ws.sent]
|
||||
assert types_sent == ["session.update", "conversation.item.create", "response.create"]
|
||||
|
||||
item = ws.sent[1]["item"]
|
||||
assert item["role"] == "user"
|
||||
assert item["content"][0]["type"] == "input_text"
|
||||
assert item["content"][0]["text"] == "Hello everyone."
|
||||
|
||||
resp = ws.sent[2]["response"]
|
||||
assert resp["modalities"] == ["audio"]
|
||||
|
||||
# Audio file got decoded + appended bytes.
|
||||
data = sink.read_bytes()
|
||||
assert data == audio_bytes + b"more"
|
||||
assert result["ok"] is True
|
||||
assert result["bytes_written"] == len(audio_bytes) + len(b"more")
|
||||
assert result["duration_ms"] >= 0.0
|
||||
|
||||
|
||||
def test_speak_raises_on_error_frame(monkeypatch, tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
ws = _FakeWS(recv_frames=[
|
||||
{"type": "response.created"},
|
||||
{"type": "error", "error": {"message": "bad juju"}},
|
||||
])
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test", audio_sink_path=tmp_path / "o.pcm")
|
||||
sess.connect()
|
||||
with pytest.raises(RuntimeError, match="bad juju"):
|
||||
sess.speak("hi")
|
||||
|
||||
|
||||
def test_speak_without_connect_raises(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test")
|
||||
with pytest.raises(RuntimeError, match="connect"):
|
||||
sess.speak("hi")
|
||||
|
||||
|
||||
def test_close_is_idempotent_and_closes_ws(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
ws = _FakeWS(recv_frames=[])
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test")
|
||||
sess.connect()
|
||||
sess.close()
|
||||
assert ws.closed is True
|
||||
# Second close is a no-op.
|
||||
sess.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# websockets dependency missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_raises_clean_error_when_websockets_missing(monkeypatch):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSession
|
||||
|
||||
# Make `import websockets.sync.client` fail.
|
||||
monkeypatch.setitem(sys.modules, "websockets", None)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync", None)
|
||||
monkeypatch.setitem(sys.modules, "websockets.sync.client", None)
|
||||
|
||||
sess = RealtimeSession(api_key="sk-test")
|
||||
with pytest.raises(RuntimeError, match="pip install websockets"):
|
||||
sess.connect()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RealtimeSpeaker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubSession:
|
||||
def __init__(self):
|
||||
self.spoken: list[str] = []
|
||||
|
||||
def speak(self, text, timeout=30.0): # noqa: ARG002
|
||||
self.spoken.append(text)
|
||||
return {"ok": True, "bytes_written": len(text), "duration_ms": 1.0}
|
||||
|
||||
|
||||
def test_speaker_run_until_stopped_processes_queue(tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
|
||||
|
||||
queue = tmp_path / "queue.jsonl"
|
||||
processed = tmp_path / "processed.jsonl"
|
||||
queue.write_text(
|
||||
json.dumps({"id": "a", "text": "hello one"}) + "\n"
|
||||
+ json.dumps({"id": "b", "text": "hello two"}) + "\n"
|
||||
)
|
||||
|
||||
stub = _StubSession()
|
||||
speaker = RealtimeSpeaker(stub, queue_path=queue, processed_path=processed)
|
||||
|
||||
# Stop once the queue is empty.
|
||||
def _stop():
|
||||
return queue.exists() and queue.read_text().strip() == ""
|
||||
|
||||
speaker.run_until_stopped(_stop, poll_interval=0.01)
|
||||
|
||||
assert stub.spoken == ["hello one", "hello two"]
|
||||
|
||||
# Processed file has both entries, in order.
|
||||
lines = [json.loads(l) for l in processed.read_text().splitlines() if l.strip()]
|
||||
assert [l["id"] for l in lines] == ["a", "b"]
|
||||
assert all(l["result"]["ok"] for l in lines)
|
||||
|
||||
# Queue is empty (possibly empty string) after processing.
|
||||
assert queue.read_text().strip() == ""
|
||||
|
||||
|
||||
def test_speaker_exits_immediately_when_stop_fn_true(tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
|
||||
|
||||
queue = tmp_path / "q.jsonl"
|
||||
queue.write_text(json.dumps({"id": "x", "text": "never spoken"}) + "\n")
|
||||
|
||||
stub = _StubSession()
|
||||
speaker = RealtimeSpeaker(stub, queue_path=queue)
|
||||
speaker.run_until_stopped(lambda: True, poll_interval=0.01)
|
||||
assert stub.spoken == []
|
||||
|
||||
|
||||
def test_speaker_drops_line_without_processed_path_when_none(tmp_path):
|
||||
from plugins.google_meet.realtime.openai_client import RealtimeSpeaker
|
||||
|
||||
queue = tmp_path / "q.jsonl"
|
||||
queue.write_text(json.dumps({"id": "only", "text": "once"}) + "\n")
|
||||
|
||||
stub = _StubSession()
|
||||
speaker = RealtimeSpeaker(stub, queue_path=queue, processed_path=None)
|
||||
|
||||
def _stop():
|
||||
return queue.read_text().strip() == ""
|
||||
|
||||
speaker.run_until_stopped(_stop, poll_interval=0.01)
|
||||
assert stub.spoken == ["once"]
|
||||
assert queue.read_text().strip() == ""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Embedded-daemon health grace timeout export (issue #13125 comment thread).
|
||||
|
||||
On resource-contended hosts the embedded Hindsight daemon can exceed a single
|
||||
2s /health check and get needlessly killed + restarted. Upstream exposes the
|
||||
grace window via HINDSIGHT_EMBED_PORT_HEALTH_GRACE_TIMEOUT (read at import
|
||||
time). The plugin surfaces it as a config.json knob and exports it to the
|
||||
process env BEFORE daemon_embed_manager is imported.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
hindsight = importlib.import_module("plugins.memory.hindsight")
|
||||
_export = hindsight._export_port_health_grace_timeout
|
||||
_ENV = hindsight._PORT_HEALTH_GRACE_ENV
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_env(monkeypatch):
|
||||
monkeypatch.delenv(_ENV, raising=False)
|
||||
|
||||
|
||||
def test_configured_value_exported(monkeypatch):
|
||||
_export({"port_health_grace_timeout": 60})
|
||||
import os
|
||||
|
||||
assert float(os.environ[_ENV]) == 60.0
|
||||
|
||||
|
||||
def test_string_value_parsed(monkeypatch):
|
||||
_export({"port_health_grace_timeout": "45"})
|
||||
import os
|
||||
|
||||
assert float(os.environ[_ENV]) == 45.0
|
||||
|
||||
|
||||
def test_blank_and_missing_are_noops(monkeypatch):
|
||||
import os
|
||||
|
||||
_export({})
|
||||
assert _ENV not in os.environ
|
||||
_export({"port_health_grace_timeout": ""})
|
||||
assert _ENV not in os.environ
|
||||
_export({"port_health_grace_timeout": None})
|
||||
assert _ENV not in os.environ
|
||||
|
||||
|
||||
def test_invalid_and_negative_ignored(monkeypatch):
|
||||
import os
|
||||
|
||||
_export({"port_health_grace_timeout": "not-a-number"})
|
||||
assert _ENV not in os.environ
|
||||
_export({"port_health_grace_timeout": -5})
|
||||
assert _ENV not in os.environ
|
||||
|
||||
|
||||
def test_explicit_env_wins_over_config(monkeypatch):
|
||||
import os
|
||||
|
||||
monkeypatch.setenv(_ENV, "99")
|
||||
_export({"port_health_grace_timeout": 60})
|
||||
# setdefault must not clobber an operator-set env override.
|
||||
assert os.environ[_ENV] == "99"
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Root-user guard for Hindsight local_embedded mode (issue #13125).
|
||||
|
||||
PostgreSQL's initdb refuses to run as root, so the embedded Hindsight daemon
|
||||
can never initialize under root — without a guard it crash-restart loops
|
||||
forever, burning RAM/CPU with no user-visible error. initialize() must detect
|
||||
root up front, skip daemon startup, disable the provider, and warn the user.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
hindsight = importlib.import_module("plugins.memory.hindsight")
|
||||
HindsightMemoryProvider = hindsight.HindsightMemoryProvider
|
||||
|
||||
|
||||
def _make_local_embedded_provider(monkeypatch):
|
||||
"""Build a provider wired for local_embedded with a passing runtime probe."""
|
||||
monkeypatch.setattr(
|
||||
hindsight,
|
||||
"_load_config",
|
||||
lambda: {"mode": "local_embedded", "profile": "hermes"},
|
||||
)
|
||||
# Pretend the local runtime imports cleanly so initialize() reaches the
|
||||
# daemon-start branch instead of bailing on a missing `hindsight` package.
|
||||
monkeypatch.setattr(hindsight, "_check_local_runtime", lambda: (True, None))
|
||||
return HindsightMemoryProvider()
|
||||
|
||||
|
||||
def _daemon_threads_alive() -> list[str]:
|
||||
return [t.name for t in threading.enumerate() if t.name == "hindsight-daemon-start"]
|
||||
|
||||
|
||||
def test_local_embedded_skips_daemon_as_root(monkeypatch, caplog):
|
||||
"""As root, the daemon thread must NOT start and the mode is disabled."""
|
||||
provider = _make_local_embedded_provider(monkeypatch)
|
||||
monkeypatch.setattr(hindsight.os, "geteuid", lambda: 0, raising=False)
|
||||
|
||||
# If the guard fails, _start_daemon would call _get_client() — make that
|
||||
# explode so a regression is loud rather than silently spawning a thread.
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_get_client",
|
||||
lambda: pytest.fail("daemon startup attempted while running as root"),
|
||||
)
|
||||
|
||||
before = set(_daemon_threads_alive())
|
||||
with caplog.at_level("WARNING", logger="plugins.memory.hindsight"):
|
||||
provider.initialize(session_id="s1")
|
||||
|
||||
assert provider._mode == "disabled"
|
||||
assert set(_daemon_threads_alive()) == before # no new daemon thread
|
||||
# The warning is surfaced to the user via the logger AND printed to
|
||||
# stderr (E2E-verified in tests/plugins/test_hindsight_root_guard.py
|
||||
# docstring rationale); capsys can't reliably capture the module-level
|
||||
# sys.stderr write under the isolation harness, so assert on the log.
|
||||
assert any("cannot run as root" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_local_embedded_starts_daemon_as_non_root(monkeypatch):
|
||||
"""As a non-root user, the daemon-start thread IS spawned."""
|
||||
provider = _make_local_embedded_provider(monkeypatch)
|
||||
monkeypatch.setattr(hindsight.os, "geteuid", lambda: 1000, raising=False)
|
||||
|
||||
started = threading.Event()
|
||||
monkeypatch.setattr(
|
||||
hindsight.threading,
|
||||
"Thread",
|
||||
_fake_thread_factory(started),
|
||||
)
|
||||
|
||||
provider.initialize(session_id="s1")
|
||||
|
||||
assert provider._mode == "local_embedded"
|
||||
assert started.is_set()
|
||||
|
||||
|
||||
def _fake_thread_factory(started: threading.Event):
|
||||
"""Return a Thread replacement that records start() without running work."""
|
||||
real_thread = threading.Thread
|
||||
|
||||
def _factory(*args, **kwargs):
|
||||
if kwargs.get("name") == "hindsight-daemon-start":
|
||||
started.set()
|
||||
|
||||
class _NoopThread:
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
return _NoopThread()
|
||||
return real_thread(*args, **kwargs)
|
||||
|
||||
return _factory
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for Kanban task file attachments (#35338).
|
||||
|
||||
Covers three layers:
|
||||
* ``hermes_cli.kanban_db`` accessors (add/list/get/delete + path helpers)
|
||||
* the dashboard REST surface (upload / list / download / delete)
|
||||
* worker-context surfacing so a kanban worker sees the absolute paths
|
||||
|
||||
The plugin router is attached to a bare FastAPI app — same approach as
|
||||
``test_kanban_dashboard_plugin.py`` — so we exercise the real HTTP path
|
||||
(multipart upload, streaming download) without the whole dashboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_plugin_router():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_dashboard_plugin_kanban_attach_test", plugin_file,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_task(conn, title="t") -> str:
|
||||
return kb.create_task(conn, title=title)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-layer accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_list_get_delete_attachment(kanban_home, tmp_path):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
# Write a real blob under the per-task dir so delete can unlink it.
|
||||
dest_dir = kb.task_attachments_dir(task_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
blob = dest_dir / "source.pdf"
|
||||
blob.write_bytes(b"%PDF-1.4 fake")
|
||||
|
||||
att_id = kb.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename="source.pdf",
|
||||
stored_path=str(blob),
|
||||
content_type="application/pdf",
|
||||
size=blob.stat().st_size,
|
||||
uploaded_by="tester",
|
||||
)
|
||||
assert att_id > 0
|
||||
|
||||
atts = kb.list_attachments(conn, task_id)
|
||||
assert len(atts) == 1
|
||||
a = atts[0]
|
||||
assert a.filename == "source.pdf"
|
||||
assert a.content_type == "application/pdf"
|
||||
assert a.size == len(b"%PDF-1.4 fake")
|
||||
assert a.uploaded_by == "tester"
|
||||
assert a.stored_path == str(blob)
|
||||
|
||||
got = kb.get_attachment(conn, att_id)
|
||||
assert got is not None and got.id == att_id
|
||||
|
||||
removed = kb.delete_attachment(conn, att_id)
|
||||
assert removed is not None and removed.id == att_id
|
||||
assert kb.list_attachments(conn, task_id) == []
|
||||
assert not blob.exists(), "delete should unlink the on-disk blob"
|
||||
assert kb.get_attachment(conn, att_id) is None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_add_attachment_rejects_unknown_task(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
kb.add_attachment(
|
||||
conn, "t_doesnotexist", filename="x.txt", stored_path="/tmp/x.txt"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_add_attachment_appends_event(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
kb.add_attachment(
|
||||
conn, task_id, filename="a.txt", stored_path="/tmp/a.txt", size=3
|
||||
)
|
||||
kinds = [e.kind for e in kb.list_events(conn, task_id)]
|
||||
assert "attached" in kinds
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_delete_attachment_missing_returns_none(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.delete_attachment(conn, 999999) is None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_attachments_root_is_per_board(kanban_home, monkeypatch):
|
||||
# default board uses <root>/kanban/attachments
|
||||
default_root = kb.attachments_root(board="default")
|
||||
assert default_root.name == "attachments"
|
||||
# a named board nests under its board dir
|
||||
monkeypatch.delenv("HERMES_KANBAN_ATTACHMENTS_ROOT", raising=False)
|
||||
named = kb.attachments_root(board="default")
|
||||
assert named == default_root
|
||||
|
||||
|
||||
def test_attachments_root_env_override(kanban_home, monkeypatch, tmp_path):
|
||||
override = tmp_path / "custom-attach"
|
||||
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(override))
|
||||
assert kb.attachments_root() == override
|
||||
assert kb.task_attachments_dir("t_abc") == override / "t_abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker context surfacing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_worker_context_lists_attachments_with_absolute_path(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn, title="translate PDF")
|
||||
dest_dir = kb.task_attachments_dir(task_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
blob = dest_dir / "manual.pdf"
|
||||
blob.write_bytes(b"data")
|
||||
kb.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename="manual.pdf",
|
||||
stored_path=str(blob.resolve()),
|
||||
content_type="application/pdf",
|
||||
size=4,
|
||||
)
|
||||
ctx = kb.build_worker_context(conn, task_id)
|
||||
assert "## Attachments" in ctx
|
||||
assert "manual.pdf" in ctx
|
||||
# The absolute path must appear so the worker can read_file it.
|
||||
assert str(blob.resolve()) in ctx
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_worker_context_no_attachments_section_when_empty(kanban_home):
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = _make_task(conn)
|
||||
ctx = kb.build_worker_context(conn, task_id)
|
||||
assert "## Attachments" not in ctx
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST surface — upload / list / download / delete round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_task_via_api(client) -> str:
|
||||
r = client.post("/api/plugins/kanban/tasks", json={"title": "x"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["task"]["id"]
|
||||
|
||||
|
||||
def test_upload_list_download_delete_roundtrip(client):
|
||||
task_id = _create_task_via_api(client)
|
||||
content = b"hello attachment world"
|
||||
|
||||
# Upload
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments",
|
||||
files={"file": ("notes.txt", content, "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
att = r.json()["attachment"]
|
||||
assert att["filename"] == "notes.txt"
|
||||
assert att["size"] == len(content)
|
||||
att_id = att["id"]
|
||||
|
||||
# List (drawer also embeds it in GET /tasks/:id)
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{task_id}/attachments")
|
||||
assert r.status_code == 200
|
||||
assert [a["filename"] for a in r.json()["attachments"]] == ["notes.txt"]
|
||||
|
||||
detail = client.get(f"/api/plugins/kanban/tasks/{task_id}").json()
|
||||
assert "attachments" in detail
|
||||
assert len(detail["attachments"]) == 1
|
||||
|
||||
# Download streams the exact bytes back
|
||||
r = client.get(f"/api/plugins/kanban/attachments/{att_id}")
|
||||
assert r.status_code == 200
|
||||
assert r.content == content
|
||||
|
||||
# Delete removes the row and the file
|
||||
r = client.delete(f"/api/plugins/kanban/attachments/{att_id}")
|
||||
assert r.status_code == 200
|
||||
assert client.get(f"/api/plugins/kanban/attachments/{att_id}").status_code == 404
|
||||
assert client.get(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments"
|
||||
).json()["attachments"] == []
|
||||
|
||||
|
||||
def test_upload_sanitizes_traversal_filename(client):
|
||||
task_id = _create_task_via_api(client)
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments",
|
||||
files={"file": ("../../../../etc/passwd", b"x", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
stored_path = r.json()["attachment"]["stored_path"]
|
||||
# The leaf name only; never escapes the per-task attachments dir.
|
||||
assert Path(stored_path).name == "passwd"
|
||||
task_dir = kb.task_attachments_dir(task_id).resolve()
|
||||
assert Path(stored_path).resolve().is_relative_to(task_dir)
|
||||
|
||||
|
||||
def test_upload_name_collision_gets_suffixed(client):
|
||||
task_id = _create_task_via_api(client)
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments",
|
||||
files={"file": ("dup.txt", b"a", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
names = sorted(
|
||||
a["filename"]
|
||||
for a in client.get(
|
||||
f"/api/plugins/kanban/tasks/{task_id}/attachments"
|
||||
).json()["attachments"]
|
||||
)
|
||||
assert names == ["dup (1).txt", "dup.txt"]
|
||||
|
||||
|
||||
def test_upload_unknown_task_404(client):
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks/t_nope/attachments",
|
||||
files={"file": ("x.txt", b"x", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_download_unknown_attachment_404(client):
|
||||
assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
"""Tests for kanban worker/runs read endpoints.
|
||||
|
||||
Covers:
|
||||
GET /workers/active
|
||||
GET /runs/{run_id}
|
||||
GET /runs/{run_id}/inspect
|
||||
POST /runs/{run_id}/terminate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_plugin_router():
|
||||
"""Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
|
||||
|
||||
mod_name = "hermes_dashboard_plugin_kanban_worker_runs_test"
|
||||
# Re-use a cached module if already loaded to avoid duplicate-router issues.
|
||||
if mod_name in sys.modules:
|
||||
return sys.modules[mod_name].router
|
||||
|
||||
spec = importlib.util.spec_from_file_location(mod_name, plugin_file)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[mod_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with an empty kanban DB."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _insert_run(conn, task_id, *, worker_pid=None, ended_at=None):
|
||||
"""Insert a task_runs row directly (bypassing claim machinery) and return run_id."""
|
||||
lock = secrets.token_hex(8)
|
||||
future = int(time.time()) + 3600
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_runs "
|
||||
"(task_id, status, claim_lock, claim_expires, worker_pid, started_at, ended_at) "
|
||||
"VALUES (?, 'running', ?, ?, ?, ?, ?)",
|
||||
(task_id, lock, future, worker_pid, int(time.time()), ended_at),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /workers/active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_workers_active_empty_board(client):
|
||||
"""Board with no running tasks returns an empty workers list."""
|
||||
r = client.get("/api/plugins/kanban/workers/active")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["workers"] == []
|
||||
assert body["count"] == 0
|
||||
assert "checked_at" in body
|
||||
|
||||
|
||||
def test_workers_active_with_running_task(client):
|
||||
"""A running task with an open run row and worker_pid appears in the list."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="active-worker", assignee="alice")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running' WHERE id=?", (task_id,),
|
||||
)
|
||||
_insert_run(conn, task_id, worker_pid=12345)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/plugins/kanban/workers/active")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["count"] == 1
|
||||
w = body["workers"][0]
|
||||
assert w["task_id"] == task_id
|
||||
assert w["worker_pid"] == 12345
|
||||
assert w["task_status"] == "running"
|
||||
assert w["task_title"] == "active-worker"
|
||||
assert w["task_assignee"] == "alice"
|
||||
|
||||
|
||||
def test_workers_active_excludes_ended_runs(client):
|
||||
"""Runs with ended_at set are excluded even if task is running."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="ended-run", assignee="bob")
|
||||
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (task_id,))
|
||||
_insert_run(conn, task_id, worker_pid=99999, ended_at=int(time.time()) - 60)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/plugins/kanban/workers/active")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["count"] == 0
|
||||
|
||||
|
||||
def test_workers_active_excludes_runs_without_pid(client):
|
||||
"""Runs with no worker_pid are not considered active workers."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="no-pid", assignee="carol")
|
||||
conn.execute("UPDATE tasks SET status='running' WHERE id=?", (task_id,))
|
||||
_insert_run(conn, task_id, worker_pid=None)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get("/api/plugins/kanban/workers/active")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["count"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /runs/{run_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_run_404_unknown_id(client):
|
||||
"""Non-existent run_id returns 404."""
|
||||
r = client.get("/api/plugins/kanban/runs/999999")
|
||||
assert r.status_code == 404
|
||||
assert "999999" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_get_run_ok(client):
|
||||
"""Existing run row returns 200 with expected shape."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="run-lookup", assignee="dave")
|
||||
run_id = _insert_run(conn, task_id, worker_pid=55555)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/runs/{run_id}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "run" in body
|
||||
run = body["run"]
|
||||
assert run["id"] == run_id
|
||||
assert run["task_id"] == task_id
|
||||
assert run["worker_pid"] == 55555
|
||||
assert run["ended_at"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /runs/{run_id}/inspect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_inspect_run_404(client):
|
||||
"""Non-existent run_id returns 404."""
|
||||
r = client.get("/api/plugins/kanban/runs/888888/inspect")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_inspect_run_already_ended(client):
|
||||
"""Run with ended_at set returns alive=false with reason."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="ended", assignee="eve")
|
||||
run_id = _insert_run(conn, task_id, worker_pid=11111, ended_at=int(time.time()) - 10)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["alive"] is False
|
||||
assert "ended" in body["reason"]
|
||||
|
||||
|
||||
def test_inspect_run_no_pid(client):
|
||||
"""Run with no worker_pid returns alive=false with reason."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="no-pid-inspect", assignee="frank")
|
||||
run_id = _insert_run(conn, task_id, worker_pid=None)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["alive"] is False
|
||||
assert "worker_pid" in body["reason"]
|
||||
|
||||
|
||||
def test_inspect_run_dead_pid(client, monkeypatch):
|
||||
"""Run with a non-existent PID returns alive=false via psutil.NoSuchProcess."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="dead-pid", assignee="grace")
|
||||
run_id = _insert_run(conn, task_id, worker_pid=999999)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Mock psutil to raise NoSuchProcess for any PID.
|
||||
mock_psutil = MagicMock()
|
||||
mock_psutil.NoSuchProcess = Exception
|
||||
mock_psutil.AccessDenied = PermissionError
|
||||
|
||||
def _raise_no_such(*args, **kwargs):
|
||||
raise mock_psutil.NoSuchProcess("no such process")
|
||||
|
||||
mock_psutil.Process = _raise_no_such
|
||||
|
||||
# Patch the module-level _psutil in the loaded plugin module.
|
||||
plugin_mod_name = "hermes_dashboard_plugin_kanban_worker_runs_test"
|
||||
plugin_mod = sys.modules.get(plugin_mod_name)
|
||||
if plugin_mod is not None:
|
||||
monkeypatch.setattr(plugin_mod, "_psutil", mock_psutil)
|
||||
else:
|
||||
pytest.skip("plugin module not yet loaded")
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["alive"] is False
|
||||
assert body["pid"] == 999999
|
||||
assert "not found" in body["reason"]
|
||||
|
||||
|
||||
def test_inspect_run_live_pid(client, monkeypatch):
|
||||
"""Run with a live PID returns alive=true with psutil fields."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="live-pid", assignee="heidi")
|
||||
run_id = _insert_run(conn, task_id, worker_pid=12345)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Build a realistic mock psutil.
|
||||
mock_psutil = MagicMock()
|
||||
mock_psutil.NoSuchProcess = type("NoSuchProcess", (Exception,), {})
|
||||
mock_psutil.AccessDenied = type("AccessDenied", (Exception,), {})
|
||||
|
||||
fake_mem = MagicMock()
|
||||
fake_mem.rss = 1024 * 1024 * 50 # 50 MB
|
||||
fake_mem.vms = 1024 * 1024 * 200
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.as_dict.return_value = {
|
||||
"cpu_percent": 3.5,
|
||||
"memory_info": fake_mem,
|
||||
"num_threads": 4,
|
||||
"status": "sleeping",
|
||||
"create_time": time.time() - 300,
|
||||
"cmdline": ["python", "-m", "hermes"],
|
||||
}
|
||||
fake_proc.num_fds.return_value = 12
|
||||
mock_psutil.Process.return_value = fake_proc
|
||||
|
||||
plugin_mod_name = "hermes_dashboard_plugin_kanban_worker_runs_test"
|
||||
plugin_mod = sys.modules.get(plugin_mod_name)
|
||||
if plugin_mod is not None:
|
||||
monkeypatch.setattr(plugin_mod, "_psutil", mock_psutil)
|
||||
else:
|
||||
pytest.skip("plugin module not yet loaded")
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/runs/{run_id}/inspect")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["alive"] is True
|
||||
assert body["pid"] == 12345
|
||||
assert body["cpu_percent"] == 3.5
|
||||
assert body["memory_rss_bytes"] == fake_mem.rss
|
||||
assert body["num_threads"] == 4
|
||||
assert body["status"] == "sleeping"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /runs/{run_id}/terminate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _setup_running_task_with_run(conn, *, title, assignee, worker_pid):
|
||||
"""Create a task in 'running' state with a matching open task_runs row.
|
||||
|
||||
Mirrors what dispatcher_claim does: stamps tasks.status='running',
|
||||
tasks.claim_lock, tasks.worker_pid; inserts task_runs row with the
|
||||
same claim_lock so reclaim_task's preconditions are satisfied.
|
||||
"""
|
||||
task_id = kb.create_task(conn, title=title, assignee=assignee)
|
||||
lock = secrets.token_hex(8)
|
||||
future = int(time.time()) + 3600
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status='running', claim_lock=?, "
|
||||
"claim_expires=?, worker_pid=? WHERE id=?",
|
||||
(lock, future, worker_pid, task_id),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_runs "
|
||||
"(task_id, status, claim_lock, claim_expires, worker_pid, started_at) "
|
||||
"VALUES (?, 'running', ?, ?, ?, ?)",
|
||||
(task_id, lock, future, worker_pid, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
return task_id, cur.lastrowid
|
||||
|
||||
|
||||
def test_terminate_run_404_unknown_id(client):
|
||||
"""POST to unknown run_id returns 404."""
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/runs/777777/terminate",
|
||||
json={"reason": "test"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert "777777" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_terminate_run_409_already_ended(client):
|
||||
"""POST against a run with ended_at set returns 409."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="ended-terminate", assignee="ivy")
|
||||
run_id = _insert_run(
|
||||
conn, task_id, worker_pid=22222, ended_at=int(time.time()) - 30,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/runs/{run_id}/terminate",
|
||||
json={"reason": "too late"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "already ended" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_terminate_run_ok(client, monkeypatch):
|
||||
"""Happy path: live run is terminated, signal fn invoked, reason recorded."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id, run_id = _setup_running_task_with_run(
|
||||
conn, title="kill-me", assignee="jane", worker_pid=33333,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Capture signal calls so we don't actually SIGTERM a random PID.
|
||||
sent = []
|
||||
|
||||
def _fake_terminate(pid, prev_lock, *, signal_fn=None):
|
||||
sent.append((pid, prev_lock))
|
||||
return {"signal": "SIGTERM", "delivered": True}
|
||||
|
||||
monkeypatch.setattr(kb, "_terminate_reclaimed_worker", _fake_terminate)
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/runs/{run_id}/terminate",
|
||||
json={"reason": "operator abort"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body == {"ok": True, "run_id": run_id, "task_id": task_id}
|
||||
assert sent == [(33333, sent[0][1])]
|
||||
assert sent[0][1] is not None # claim_lock was non-null
|
||||
|
||||
# Task is back to ready, claim cleared.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row["status"] == "ready"
|
||||
assert row["claim_lock"] is None
|
||||
assert row["worker_pid"] is None
|
||||
|
||||
|
||||
def test_terminate_run_409_task_not_reclaimable(client, monkeypatch):
|
||||
"""Open run row whose task is no longer claimable returns 409."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task_id = kb.create_task(conn, title="ghost-run", assignee="ken")
|
||||
# Task left in default 'ready' state with no claim_lock — task_run
|
||||
# exists but reclaim_task will refuse because status != 'running'
|
||||
# and claim_lock is NULL.
|
||||
run_id = _insert_run(conn, task_id, worker_pid=44444)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Make sure no signal is ever sent on this code path.
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("_terminate_reclaimed_worker should not be called")
|
||||
|
||||
monkeypatch.setattr(kb, "_terminate_reclaimed_worker", _boom)
|
||||
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/runs/{run_id}/terminate",
|
||||
json={"reason": "stale"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "reclaimable" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_terminate_run_accepts_empty_body(client):
|
||||
"""Empty JSON body (no reason) is still accepted; falls through to 404."""
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/runs/666666/terminate",
|
||||
json={},
|
||||
)
|
||||
# 404 because run doesn't exist — what we're asserting here is that
|
||||
# the endpoint doesn't 422 on a missing 'reason' field.
|
||||
assert r.status_code == 404
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
"""Guardrail: dashboard plugins must NOT read the session token directly.
|
||||
|
||||
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
|
||||
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
|
||||
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
|
||||
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
|
||||
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
|
||||
(``hermes_session_at`` cookie / single-use ``?ticket=``).
|
||||
|
||||
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
|
||||
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
|
||||
and 401/1008. That bug shipped in the kanban and achievements plugins and was
|
||||
invisible until the dashboard ran gated on hosted Fly agents.
|
||||
|
||||
This test fails if any bundled plugin's frontend reads the token global
|
||||
directly, forcing new/edited plugins through the SDK surface instead. It is
|
||||
the enforcement half of the "single sanctioned auth surface" design — the SDK
|
||||
helpers are the carrot, this test is the stick.
|
||||
|
||||
If you have a legitimate reason to reference the token name (e.g. a comment
|
||||
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Repo root: tests/plugins/<this file> → ../../
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_PLUGINS_DIR = _REPO_ROOT / "plugins"
|
||||
|
||||
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
|
||||
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
|
||||
|
||||
# Files explicitly allowed to mention the token (none today). Map path →
|
||||
# reason so the allowance is self-documenting if one is ever needed.
|
||||
_ALLOWED_FILES: dict[str, str] = {}
|
||||
|
||||
|
||||
def _plugin_frontend_bundles() -> list[Path]:
|
||||
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
|
||||
if not _PLUGINS_DIR.is_dir():
|
||||
return []
|
||||
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
|
||||
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
|
||||
|
||||
|
||||
def test_there_are_plugin_bundles_to_check() -> None:
|
||||
"""Sanity: the glob actually finds the bundles, so a future layout change
|
||||
doesn't silently turn this guard into a no-op."""
|
||||
bundles = _plugin_frontend_bundles()
|
||||
names = {b.parent.parent.parent.name for b in bundles}
|
||||
# kanban + hermes-achievements are bundled today; assert at least one is
|
||||
# found so the guard can't pass vacuously.
|
||||
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
|
||||
assert names, "could not resolve plugin names from bundle paths"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bundle",
|
||||
_plugin_frontend_bundles(),
|
||||
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
|
||||
)
|
||||
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
|
||||
rel = str(bundle.relative_to(_REPO_ROOT))
|
||||
text = bundle.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if rel in _ALLOWED_FILES:
|
||||
return # explicitly allowed (with a documented reason)
|
||||
|
||||
# Only flag CODE reads of the token global, not mentions in ``//`` comments
|
||||
# (e.g. a comment explaining why the SDK helper is used instead). A line is
|
||||
# a code read if it contains the global and the global appears before any
|
||||
# ``//`` comment marker on that line.
|
||||
offending: list[str] = []
|
||||
for i, line in enumerate(text.splitlines(), start=1):
|
||||
idx = line.find(_FORBIDDEN)
|
||||
if idx == -1:
|
||||
continue
|
||||
comment_idx = line.find("//")
|
||||
in_comment = comment_idx != -1 and comment_idx < idx
|
||||
if not in_comment:
|
||||
offending.append(f" {i}: {line.strip()}")
|
||||
|
||||
if not offending:
|
||||
return
|
||||
|
||||
pytest.fail(
|
||||
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
|
||||
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
|
||||
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
|
||||
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Regression tests for the raft platform plugin's check_fn.
|
||||
|
||||
The raft platform adapter's ``check_raft_requirements()`` is registered as
|
||||
the platform's ``check_fn``. This function is invoked on every
|
||||
``load_gateway_config()`` call (dozens of times during normal gateway
|
||||
operation). It must therefore be a *silent* predicate — returning True/False
|
||||
without logging — otherwise every user without the ``raft`` CLI installed
|
||||
gets their logs flooded with WARNING messages every few seconds.
|
||||
|
||||
See: https://github.com/NousResearch/hermes-agent/issues/49234
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raft_check():
|
||||
"""Import check_raft_requirements fresh (adapter self-manages sys.path)."""
|
||||
from plugins.platforms.raft.adapter import check_raft_requirements
|
||||
|
||||
return check_raft_requirements
|
||||
|
||||
|
||||
def test_check_returns_false_when_raft_cli_missing(raft_check):
|
||||
"""check_fn returns False when raft CLI is not in PATH."""
|
||||
with patch("plugins.platforms.raft.adapter.shutil.which", return_value=None), \
|
||||
patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", True):
|
||||
assert raft_check() is False
|
||||
|
||||
|
||||
def test_check_returns_false_when_aiohttp_missing(raft_check):
|
||||
"""check_fn returns False when aiohttp dependency is unavailable."""
|
||||
with patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", False):
|
||||
assert raft_check() is False
|
||||
|
||||
|
||||
def test_check_returns_true_when_all_deps_present(raft_check):
|
||||
"""check_fn returns True when all dependencies are available."""
|
||||
with patch("plugins.platforms.raft.adapter.shutil.which", return_value="/usr/bin/raft"), \
|
||||
patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", True):
|
||||
assert raft_check() is True
|
||||
|
||||
|
||||
def test_check_silent_when_raft_cli_missing(raft_check, caplog):
|
||||
"""check_fn must NOT log a WARNING when raft CLI is missing.
|
||||
|
||||
This is the regression guard for issue #49234 — logging inside check_fn
|
||||
causes log spam because the function is called on every config load.
|
||||
"""
|
||||
with patch("plugins.platforms.raft.adapter.shutil.which", return_value=None), \
|
||||
patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", True):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.raft.adapter"):
|
||||
raft_check()
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert warnings == [], (
|
||||
f"check_raft_requirements must be silent (no WARNING logs), "
|
||||
f"but emitted: {[r.getMessage() for r in warnings]}"
|
||||
)
|
||||
|
||||
|
||||
def test_check_silent_when_aiohttp_missing(raft_check, caplog):
|
||||
"""check_fn must NOT log a WARNING when aiohttp is missing."""
|
||||
with patch("plugins.platforms.raft.adapter.AIOHTTP_AVAILABLE", False):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.raft.adapter"):
|
||||
raft_check()
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert warnings == [], (
|
||||
f"check_raft_requirements must be silent (no WARNING logs), "
|
||||
f"but emitted: {[r.getMessage() for r in warnings]}"
|
||||
)
|
||||
@@ -0,0 +1,738 @@
|
||||
"""Tests for the RetainDB memory plugin.
|
||||
|
||||
Covers: _Client HTTP client, _WriteQueue SQLite queue, _build_overlay formatter,
|
||||
RetainDBMemoryProvider lifecycle/tools/prefetch, thread management, connection pooling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Imports — guarded since plugins/memory lives outside the standard test path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(tmp_path, monkeypatch):
|
||||
"""Ensure HERMES_HOME and RETAINDB vars are isolated."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("RETAINDB_API_KEY", raising=False)
|
||||
monkeypatch.delenv("RETAINDB_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("RETAINDB_PROJECT", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cap_retaindb_sleeps(monkeypatch):
|
||||
"""Cap production-code sleeps so background-thread tests run fast.
|
||||
|
||||
The retaindb ``_WriteQueue._flush_row`` does ``time.sleep(2)`` after
|
||||
errors. Across multiple tests that trigger the retry path, that adds
|
||||
up. Cap the module's bound ``time.sleep`` to 0.05s — tests don't care
|
||||
about the exact retry delay, only that it happens. The test file's
|
||||
own ``time.sleep`` stays real since it uses a different reference.
|
||||
"""
|
||||
try:
|
||||
from plugins.memory import retaindb as _retaindb
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
real_sleep = _retaindb.time.sleep
|
||||
|
||||
def _capped_sleep(seconds):
|
||||
return real_sleep(min(float(seconds), 0.05))
|
||||
|
||||
import types as _types
|
||||
fake_time = _types.SimpleNamespace(sleep=_capped_sleep, time=_retaindb.time.time)
|
||||
monkeypatch.setattr(_retaindb, "time", fake_time)
|
||||
|
||||
|
||||
# We need the repo root on sys.path so the plugin can import agent.memory_provider
|
||||
import sys
|
||||
_repo_root = str(Path(__file__).resolve().parents[2])
|
||||
if _repo_root not in sys.path:
|
||||
sys.path.insert(0, _repo_root)
|
||||
|
||||
from plugins.memory.retaindb import (
|
||||
_Client,
|
||||
_WriteQueue,
|
||||
_build_overlay,
|
||||
RetainDBMemoryProvider,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _Client tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestClient:
|
||||
"""Test the HTTP client with mocked requests."""
|
||||
|
||||
def _make_client(self, api_key="rdb-test-key", base_url="https://api.retaindb.com", project="test"):
|
||||
return _Client(api_key, base_url, project)
|
||||
|
||||
def test_base_url_trailing_slash_stripped(self):
|
||||
c = self._make_client(base_url="https://api.retaindb.com///")
|
||||
assert c.base_url == "https://api.retaindb.com"
|
||||
|
||||
def test_headers_include_auth(self):
|
||||
c = self._make_client()
|
||||
h = c._headers("/v1/files")
|
||||
assert h["Authorization"] == "Bearer rdb-test-key"
|
||||
assert "X-API-Key" not in h
|
||||
|
||||
def test_headers_include_api_key_for_memory_path(self):
|
||||
c = self._make_client()
|
||||
h = c._headers("/v1/memory/search")
|
||||
assert h["X-API-Key"] == "rdb-test-key"
|
||||
|
||||
def test_headers_include_api_key_for_context_path(self):
|
||||
c = self._make_client()
|
||||
h = c._headers("/v1/context/query")
|
||||
assert h["X-API-Key"] == "rdb-test-key"
|
||||
|
||||
def test_headers_strip_bearer_prefix(self):
|
||||
c = self._make_client(api_key="Bearer rdb-test-key")
|
||||
h = c._headers("/v1/memory/search")
|
||||
assert h["Authorization"] == "Bearer rdb-test-key"
|
||||
assert h["X-API-Key"] == "rdb-test-key"
|
||||
|
||||
def test_add_memory_tries_fallback(self):
|
||||
c = self._make_client()
|
||||
call_count = 0
|
||||
def fake_request(method, path, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("404")
|
||||
return {"id": "mem-1"}
|
||||
|
||||
with patch.object(c, "request", side_effect=fake_request):
|
||||
result = c.add_memory("u1", "s1", "test fact")
|
||||
assert result == {"id": "mem-1"}
|
||||
assert call_count == 2
|
||||
|
||||
def test_delete_memory_tries_fallback(self):
|
||||
c = self._make_client()
|
||||
call_count = 0
|
||||
def fake_request(method, path, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("404")
|
||||
return {"deleted": True}
|
||||
|
||||
with patch.object(c, "request", side_effect=fake_request):
|
||||
result = c.delete_memory("mem-123")
|
||||
assert result == {"deleted": True}
|
||||
assert call_count == 2
|
||||
|
||||
# ===========================================================================
|
||||
# _WriteQueue tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestWriteQueue:
|
||||
"""Test the SQLite-backed write queue with real SQLite."""
|
||||
|
||||
def _make_queue(self, tmp_path, client=None):
|
||||
if client is None:
|
||||
client = MagicMock()
|
||||
client.ingest_session = MagicMock(return_value={"status": "ok"})
|
||||
db_path = tmp_path / "test_queue.db"
|
||||
return _WriteQueue(client, db_path), client, db_path
|
||||
|
||||
def test_enqueue_creates_row(self, tmp_path):
|
||||
q, client, db_path = self._make_queue(tmp_path)
|
||||
q.enqueue("user1", "sess1", [{"role": "user", "content": "hi"}])
|
||||
# shutdown() blocks until the writer thread drains the queue — no need
|
||||
# to pre-sleep (the old 1s sleep was a just-in-case wait, but shutdown
|
||||
# does the right thing).
|
||||
q.shutdown()
|
||||
# If ingest succeeded, the row should be deleted
|
||||
client.ingest_session.assert_called_once()
|
||||
|
||||
def test_enqueue_persists_to_sqlite(self, tmp_path):
|
||||
client = MagicMock()
|
||||
# Make ingest slow so the row is still in SQLite when we peek.
|
||||
# 0.5s is plenty — the test just needs the flush to still be in-flight.
|
||||
client.ingest_session = MagicMock(side_effect=lambda *a, **kw: time.sleep(0.5))
|
||||
db_path = tmp_path / "test_queue.db"
|
||||
q = _WriteQueue(client, db_path)
|
||||
q.enqueue("user1", "sess1", [{"role": "user", "content": "test"}])
|
||||
# Check SQLite directly — row should exist since flush is slow
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
rows = conn.execute("SELECT user_id, session_id FROM pending").fetchall()
|
||||
conn.close()
|
||||
assert len(rows) >= 1
|
||||
assert rows[0][0] == "user1"
|
||||
q.shutdown()
|
||||
|
||||
def test_flush_deletes_row_on_success(self, tmp_path):
|
||||
q, client, db_path = self._make_queue(tmp_path)
|
||||
q.enqueue("user1", "sess1", [{"role": "user", "content": "hi"}])
|
||||
q.shutdown() # blocks until drain
|
||||
# Row should be gone
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
rows = conn.execute("SELECT COUNT(*) FROM pending").fetchone()[0]
|
||||
conn.close()
|
||||
assert rows == 0
|
||||
|
||||
def test_flush_records_error_on_failure(self, tmp_path):
|
||||
client = MagicMock()
|
||||
client.ingest_session = MagicMock(side_effect=RuntimeError("API down"))
|
||||
db_path = tmp_path / "test_queue.db"
|
||||
q = _WriteQueue(client, db_path)
|
||||
q.enqueue("user1", "sess1", [{"role": "user", "content": "hi"}])
|
||||
# Poll for the error to be recorded (max 2s), instead of a fixed 3s wait.
|
||||
deadline = time.time() + 2.0
|
||||
last_error = None
|
||||
while time.time() < deadline:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT last_error FROM pending").fetchone()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
last_error = row[0]
|
||||
break
|
||||
time.sleep(0.05)
|
||||
q.shutdown()
|
||||
assert last_error is not None
|
||||
assert "API down" in last_error
|
||||
|
||||
def test_thread_local_connection_reuse(self, tmp_path):
|
||||
q, _, _ = self._make_queue(tmp_path)
|
||||
# Same thread should get same connection
|
||||
conn1 = q._get_conn()
|
||||
conn2 = q._get_conn()
|
||||
assert conn1 is conn2
|
||||
q.shutdown()
|
||||
|
||||
def test_crash_recovery_replays_pending(self, tmp_path):
|
||||
"""Simulate crash: create rows, then new queue should replay them."""
|
||||
db_path = tmp_path / "recovery_test.db"
|
||||
# First: create a queue and insert rows, but don't let them flush
|
||||
client1 = MagicMock()
|
||||
client1.ingest_session = MagicMock(side_effect=RuntimeError("fail"))
|
||||
q1 = _WriteQueue(client1, db_path)
|
||||
q1.enqueue("user1", "sess1", [{"role": "user", "content": "lost turn"}])
|
||||
# Wait until the error is recorded (poll with short interval).
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
row = conn.execute("SELECT last_error FROM pending").fetchone()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
q1.shutdown()
|
||||
|
||||
# Now create a new queue — it should replay the pending rows
|
||||
client2 = MagicMock()
|
||||
client2.ingest_session = MagicMock(return_value={"status": "ok"})
|
||||
q2 = _WriteQueue(client2, db_path)
|
||||
# Poll for the replay to happen.
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
if client2.ingest_session.called:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
q2.shutdown()
|
||||
|
||||
# The replayed row should have been ingested via client2
|
||||
client2.ingest_session.assert_called_once()
|
||||
call_args = client2.ingest_session.call_args
|
||||
assert call_args[0][0] == "user1" # user_id
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _build_overlay tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestBuildOverlay:
|
||||
"""Test the overlay formatter (pure function)."""
|
||||
|
||||
def test_empty_inputs_returns_empty(self):
|
||||
assert _build_overlay({}, {}) == ""
|
||||
|
||||
def test_empty_memories_returns_empty(self):
|
||||
assert _build_overlay({"memories": []}, {"results": []}) == ""
|
||||
|
||||
def test_profile_items_included(self):
|
||||
profile = {"memories": [{"content": "User likes Python"}]}
|
||||
result = _build_overlay(profile, {})
|
||||
assert "User likes Python" in result
|
||||
assert "[RetainDB Context]" in result
|
||||
|
||||
def test_query_results_included(self):
|
||||
query_result = {"results": [{"content": "Previous discussion about Rust"}]}
|
||||
result = _build_overlay({}, query_result)
|
||||
assert "Previous discussion about Rust" in result
|
||||
|
||||
def test_deduplication_removes_duplicates(self):
|
||||
profile = {"memories": [{"content": "User likes Python"}]}
|
||||
query_result = {"results": [{"content": "User likes Python"}]}
|
||||
result = _build_overlay(profile, query_result)
|
||||
assert result.count("User likes Python") == 1
|
||||
|
||||
def test_local_entries_filter(self):
|
||||
profile = {"memories": [{"content": "Already known fact"}]}
|
||||
result = _build_overlay(profile, {}, local_entries=["Already known fact"])
|
||||
# The profile item matches a local entry, should be filtered
|
||||
assert result == ""
|
||||
|
||||
def test_max_five_items_per_section(self):
|
||||
profile = {"memories": [{"content": f"Fact {i}"} for i in range(10)]}
|
||||
result = _build_overlay(profile, {})
|
||||
# Should only include first 5
|
||||
assert "Fact 0" in result
|
||||
assert "Fact 4" in result
|
||||
assert "Fact 5" not in result
|
||||
|
||||
def test_none_content_handled(self):
|
||||
profile = {"memories": [{"content": None}, {"content": "Real fact"}]}
|
||||
result = _build_overlay(profile, {})
|
||||
assert "Real fact" in result
|
||||
|
||||
def test_truncation_at_320_chars(self):
|
||||
long_content = "x" * 500
|
||||
profile = {"memories": [{"content": long_content}]}
|
||||
result = _build_overlay(profile, {})
|
||||
# Each item is compacted to 320 chars max
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("- "):
|
||||
assert len(line) <= 322 # "- " + 320
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RetainDBMemoryProvider tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestRetainDBMemoryProvider:
|
||||
"""Test the main plugin class."""
|
||||
|
||||
def _make_provider(self, tmp_path, monkeypatch, api_key="rdb-test-key"):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", api_key)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
provider = RetainDBMemoryProvider()
|
||||
return provider
|
||||
|
||||
def test_name(self):
|
||||
p = RetainDBMemoryProvider()
|
||||
assert p.name == "retaindb"
|
||||
|
||||
def test_is_available_without_key(self):
|
||||
p = RetainDBMemoryProvider()
|
||||
assert p.is_available() is False
|
||||
|
||||
def test_is_available_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test")
|
||||
p = RetainDBMemoryProvider()
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_config_schema(self):
|
||||
p = RetainDBMemoryProvider()
|
||||
schema = p.get_config_schema()
|
||||
assert len(schema) == 3
|
||||
keys = [s["key"] for s in schema]
|
||||
assert "api_key" in keys
|
||||
assert "base_url" in keys
|
||||
assert "project" in keys
|
||||
|
||||
def test_initialize_creates_client_and_queue(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
assert p._client is not None
|
||||
assert p._queue is not None
|
||||
assert p._session_id == "test-session"
|
||||
p.shutdown()
|
||||
|
||||
def test_initialize_default_project(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
assert p._client.project == "default"
|
||||
p.shutdown()
|
||||
|
||||
def test_initialize_explicit_project(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_PROJECT", "my-project")
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
assert p._client.project == "my-project"
|
||||
p.shutdown()
|
||||
|
||||
def test_initialize_profile_project(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
profile_home = str(tmp_path / "profiles" / "coder")
|
||||
p.initialize("test-session", hermes_home=profile_home)
|
||||
assert p._client.project == "hermes-coder"
|
||||
p.shutdown()
|
||||
|
||||
def test_initialize_seeds_soul_md(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
soul_path = tmp_path / ".hermes" / "SOUL.md"
|
||||
soul_path.write_text("I am a helpful agent.")
|
||||
with patch.object(RetainDBMemoryProvider, "_seed_soul") as mock_seed:
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
# Give thread time to start
|
||||
time.sleep(0.5)
|
||||
mock_seed.assert_called_once_with("I am a helpful agent.")
|
||||
p.shutdown()
|
||||
|
||||
def test_system_prompt_block(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
block = p.system_prompt_block()
|
||||
assert "RetainDB Memory" in block
|
||||
assert "Active" in block
|
||||
p.shutdown()
|
||||
|
||||
def test_handle_tool_call_not_initialized(self):
|
||||
p = RetainDBMemoryProvider()
|
||||
result = json.loads(p.handle_tool_call("retaindb_profile", {}))
|
||||
assert "error" in result
|
||||
assert "not initialized" in result["error"]
|
||||
|
||||
def test_handle_tool_call_unknown_tool(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_nonexistent", {}))
|
||||
assert result == {"error": "Unknown tool: retaindb_nonexistent"}
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_profile(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "get_profile", return_value={"memories": []}):
|
||||
result = json.loads(p.handle_tool_call("retaindb_profile", {}))
|
||||
assert "memories" in result
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_search_requires_query(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_search", {}))
|
||||
assert result == {"error": "query is required"}
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_search(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "search", return_value={"results": [{"content": "found"}]}):
|
||||
result = json.loads(p.handle_tool_call("retaindb_search", {"query": "test"}))
|
||||
assert "results" in result
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_search_top_k_capped(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "search") as mock_search:
|
||||
mock_search.return_value = {"results": []}
|
||||
p.handle_tool_call("retaindb_search", {"query": "test", "top_k": 100})
|
||||
# top_k should be capped at 20
|
||||
assert mock_search.call_args[1]["top_k"] == 20
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_remember(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "add_memory", return_value={"id": "mem-1"}):
|
||||
result = json.loads(p.handle_tool_call("retaindb_remember", {"content": "test fact"}))
|
||||
assert result["id"] == "mem-1"
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_remember_requires_content(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_remember", {}))
|
||||
assert result == {"error": "content is required"}
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_forget(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "delete_memory", return_value={"deleted": True}):
|
||||
result = json.loads(p.handle_tool_call("retaindb_forget", {"memory_id": "mem-1"}))
|
||||
assert result["deleted"] is True
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_forget_requires_id(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_forget", {}))
|
||||
assert result == {"error": "memory_id is required"}
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_context(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "query_context", return_value={"results": [{"content": "relevant"}]}), \
|
||||
patch.object(p._client, "get_profile", return_value={"memories": []}):
|
||||
result = json.loads(p.handle_tool_call("retaindb_context", {"query": "current task"}))
|
||||
assert "context" in result
|
||||
assert "raw" in result
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_file_list(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "list_files", return_value={"files": []}):
|
||||
result = json.loads(p.handle_tool_call("retaindb_list_files", {}))
|
||||
assert "files" in result
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_file_upload_missing_path(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_upload_file", {}))
|
||||
assert "error" in result
|
||||
|
||||
def test_dispatch_file_upload_not_found(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_upload_file", {"local_path": "/nonexistent/file.txt"}))
|
||||
assert "File not found" in result["error"]
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_file_read_requires_id(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_read_file", {}))
|
||||
assert result == {"error": "file_id is required"}
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_file_ingest_requires_id(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_ingest_file", {}))
|
||||
assert result == {"error": "file_id is required"}
|
||||
p.shutdown()
|
||||
|
||||
def test_dispatch_file_delete_requires_id(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
result = json.loads(p.handle_tool_call("retaindb_delete_file", {}))
|
||||
assert result == {"error": "file_id is required"}
|
||||
p.shutdown()
|
||||
|
||||
def test_handle_tool_call_wraps_exception(self, tmp_path, monkeypatch):
|
||||
p = self._make_provider(tmp_path, monkeypatch)
|
||||
p.initialize("test-session", hermes_home=str(tmp_path / ".hermes"))
|
||||
with patch.object(p._client, "get_profile", side_effect=RuntimeError("API exploded")):
|
||||
result = json.loads(p.handle_tool_call("retaindb_profile", {}))
|
||||
assert "API exploded" in result["error"]
|
||||
p.shutdown()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Prefetch and thread management tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestPrefetch:
|
||||
"""Test background prefetch and thread accumulation prevention."""
|
||||
|
||||
def _make_initialized_provider(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
return p
|
||||
|
||||
def test_queue_prefetch_skips_without_client(self):
|
||||
p = RetainDBMemoryProvider()
|
||||
p.queue_prefetch("test") # Should not raise
|
||||
|
||||
def test_prefetch_returns_empty_when_nothing_cached(self, tmp_path, monkeypatch):
|
||||
p = self._make_initialized_provider(tmp_path, monkeypatch)
|
||||
result = p.prefetch("test")
|
||||
assert result == ""
|
||||
p.shutdown()
|
||||
|
||||
def test_prefetch_consumes_context_result(self, tmp_path, monkeypatch):
|
||||
p = self._make_initialized_provider(tmp_path, monkeypatch)
|
||||
# Manually set the cached result
|
||||
with p._lock:
|
||||
p._context_result = "[RetainDB Context]\nProfile:\n- User likes tests"
|
||||
result = p.prefetch("test")
|
||||
assert "User likes tests" in result
|
||||
# Should be consumed
|
||||
assert p.prefetch("test") == ""
|
||||
p.shutdown()
|
||||
|
||||
def test_prefetch_consumes_dialectic_result(self, tmp_path, monkeypatch):
|
||||
p = self._make_initialized_provider(tmp_path, monkeypatch)
|
||||
with p._lock:
|
||||
p._dialectic_result = "User is a software engineer who prefers Python."
|
||||
result = p.prefetch("test")
|
||||
assert "[RetainDB User Synthesis]" in result
|
||||
assert "software engineer" in result
|
||||
p.shutdown()
|
||||
|
||||
def test_prefetch_consumes_agent_model(self, tmp_path, monkeypatch):
|
||||
p = self._make_initialized_provider(tmp_path, monkeypatch)
|
||||
with p._lock:
|
||||
p._agent_model = {
|
||||
"memory_count": 5,
|
||||
"persona": "Helpful coding assistant",
|
||||
"persistent_instructions": ["Be concise", "Use Python"],
|
||||
"working_style": "Direct and efficient",
|
||||
}
|
||||
result = p.prefetch("test")
|
||||
assert "[RetainDB Agent Self-Model]" in result
|
||||
assert "Helpful coding assistant" in result
|
||||
assert "Be concise" in result
|
||||
assert "Direct and efficient" in result
|
||||
p.shutdown()
|
||||
|
||||
def test_prefetch_skips_empty_agent_model(self, tmp_path, monkeypatch):
|
||||
p = self._make_initialized_provider(tmp_path, monkeypatch)
|
||||
with p._lock:
|
||||
p._agent_model = {"memory_count": 0}
|
||||
result = p.prefetch("test")
|
||||
assert "Agent Self-Model" not in result
|
||||
p.shutdown()
|
||||
|
||||
def test_thread_accumulation_guard(self, tmp_path, monkeypatch):
|
||||
"""Verify old prefetch threads are joined before new ones spawn."""
|
||||
p = self._make_initialized_provider(tmp_path, monkeypatch)
|
||||
# Mock the prefetch methods to be slow
|
||||
with patch.object(p, "_prefetch_context", side_effect=lambda q: time.sleep(0.5)), \
|
||||
patch.object(p, "_prefetch_dialectic", side_effect=lambda q: time.sleep(0.5)), \
|
||||
patch.object(p, "_prefetch_agent_model", side_effect=lambda: time.sleep(0.5)):
|
||||
p.queue_prefetch("query 1")
|
||||
first_threads = list(p._prefetch_threads)
|
||||
assert len(first_threads) == 3
|
||||
|
||||
# Call again — should join first batch before spawning new
|
||||
p.queue_prefetch("query 2")
|
||||
second_threads = list(p._prefetch_threads)
|
||||
assert len(second_threads) == 3
|
||||
# Should be different thread objects
|
||||
for t in second_threads:
|
||||
assert t not in first_threads
|
||||
p.shutdown()
|
||||
|
||||
def test_reasoning_level_short(self):
|
||||
assert RetainDBMemoryProvider._reasoning_level("hi") == "low"
|
||||
|
||||
def test_reasoning_level_medium(self):
|
||||
assert RetainDBMemoryProvider._reasoning_level("x" * 200) == "medium"
|
||||
|
||||
def test_reasoning_level_long(self):
|
||||
assert RetainDBMemoryProvider._reasoning_level("x" * 500) == "high"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# sync_turn tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestSyncTurn:
|
||||
"""Test turn synchronization via the write queue."""
|
||||
|
||||
def test_sync_turn_enqueues(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
with patch.object(p._queue, "enqueue") as mock_enqueue:
|
||||
p.sync_turn("user msg", "assistant msg")
|
||||
mock_enqueue.assert_called_once()
|
||||
args = mock_enqueue.call_args[0]
|
||||
assert args[0] == "default" # user_id
|
||||
assert args[1] == "test-session" # session_id
|
||||
msgs = args[2]
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
p.shutdown()
|
||||
|
||||
def test_sync_turn_skips_empty_user_content(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
with patch.object(p._queue, "enqueue") as mock_enqueue:
|
||||
p.sync_turn("", "assistant msg")
|
||||
mock_enqueue.assert_not_called()
|
||||
p.shutdown()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# on_memory_write hook tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestOnMemoryWrite:
|
||||
"""Test the built-in memory mirror hook."""
|
||||
|
||||
def test_mirrors_add_action(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
with patch.object(p._client, "add_memory", return_value={"id": "mem-1"}) as mock_add:
|
||||
p.on_memory_write("add", "user", "User prefers dark mode")
|
||||
mock_add.assert_called_once()
|
||||
assert mock_add.call_args[1]["memory_type"] == "preference"
|
||||
p.shutdown()
|
||||
|
||||
def test_skips_non_add_action(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
with patch.object(p._client, "add_memory") as mock_add:
|
||||
p.on_memory_write("remove", "user", "something")
|
||||
mock_add.assert_not_called()
|
||||
p.shutdown()
|
||||
|
||||
def test_skips_empty_content(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
with patch.object(p._client, "add_memory") as mock_add:
|
||||
p.on_memory_write("add", "user", "")
|
||||
mock_add.assert_not_called()
|
||||
p.shutdown()
|
||||
|
||||
def test_memory_target_maps_to_type(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "rdb-test-key")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
p = RetainDBMemoryProvider()
|
||||
p.initialize("test-session", hermes_home=str(hermes_home))
|
||||
with patch.object(p._client, "add_memory", return_value={"id": "mem-1"}) as mock_add:
|
||||
p.on_memory_write("add", "memory", "Some env fact")
|
||||
assert mock_add.call_args[1]["memory_type"] == "factual"
|
||||
p.shutdown()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# register() test
|
||||
# ===========================================================================
|
||||
|
||||
class TestRegister:
|
||||
def test_register_calls_register_memory_provider(self):
|
||||
from plugins.memory.retaindb import register
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
ctx.register_memory_provider.assert_called_once()
|
||||
arg = ctx.register_memory_provider.call_args[0][0]
|
||||
assert isinstance(arg, RetainDBMemoryProvider)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Tests for the security-guidance plugin.
|
||||
|
||||
Covers ``plugins/security-guidance/``:
|
||||
|
||||
* ``patterns.py`` data integrity — every rule has a ``RuleId``, the
|
||||
fail-loud import assertion is wired.
|
||||
* ``_scan_content`` — true positives (pickle.load, yaml.load, eval,
|
||||
dangerouslySetInnerHTML, GitHub Actions workflow), true negatives
|
||||
(.md skips Python rules, ``model.eval()`` doesn't trip eval),
|
||||
path-only rules (``path_check``), content-only rules
|
||||
(``path_filter``).
|
||||
* Hooks — ``transform_tool_result`` appends a warning block in warn
|
||||
mode and stays out of error results; ``pre_tool_call`` blocks
|
||||
writes when ``SECURITY_GUIDANCE_BLOCK=1`` and stays silent
|
||||
otherwise.
|
||||
* Bundled-plugin discovery via ``PluginManager.discover_and_load``.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("SECURITY_GUIDANCE_BLOCK", raising=False)
|
||||
monkeypatch.delenv("SECURITY_GUIDANCE_DISABLE", raising=False)
|
||||
yield hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_patterns():
|
||||
"""Import patterns.py in isolation (no plugin glue)."""
|
||||
pat_path = _repo_root() / "plugins" / "security-guidance" / "patterns.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"security_guidance_patterns_under_test", pat_path
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_plugin_init():
|
||||
"""Import the plugin __init__.py with patterns.py as a sibling."""
|
||||
plugin_dir = _repo_root() / "plugins" / "security-guidance"
|
||||
if "hermes_plugins" not in sys.modules:
|
||||
ns = types.ModuleType("hermes_plugins")
|
||||
ns.__path__ = []
|
||||
sys.modules["hermes_plugins"] = ns
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_plugins.security_guidance",
|
||||
plugin_dir / "__init__.py",
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
mod.__package__ = "hermes_plugins.security_guidance"
|
||||
mod.__path__ = [str(plugin_dir)]
|
||||
sys.modules["hermes_plugins.security_guidance"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# patterns.py data integrity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPatternsData:
|
||||
def test_has_at_least_one_rule(self):
|
||||
p = _load_patterns()
|
||||
assert len(p.SECURITY_PATTERNS) >= 1
|
||||
|
||||
def test_every_rule_has_required_fields(self):
|
||||
p = _load_patterns()
|
||||
for rule in p.SECURITY_PATTERNS:
|
||||
assert "ruleName" in rule
|
||||
assert "reminder" in rule and rule["reminder"]
|
||||
# At least one of substrings/regex/path_check must be present —
|
||||
# otherwise the rule could never fire.
|
||||
assert any(k in rule for k in ("substrings", "regex", "path_check")), rule
|
||||
|
||||
def test_rule_names_are_unique(self):
|
||||
p = _load_patterns()
|
||||
names = [r["ruleName"] for r in p.SECURITY_PATTERNS]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_rule_id_enum_in_sync(self):
|
||||
# The upstream patterns.py asserts this at import time. If the
|
||||
# set diverges, the import itself raises and this test fails.
|
||||
p = _load_patterns()
|
||||
rule_names = {r["ruleName"] for r in p.SECURITY_PATTERNS}
|
||||
enum_names = set(p._RULE_NAME_TO_ID)
|
||||
assert rule_names == enum_names
|
||||
|
||||
def test_rule_names_to_mask_packs_bits(self):
|
||||
p = _load_patterns()
|
||||
# PICKLE_DESERIALIZATION = 8, EVAL_INJECTION = 4 → bits 8 and 4 set.
|
||||
mask = p.rule_names_to_mask({"pickle_deserialization", "eval_injection"})
|
||||
assert mask & (1 << p.RuleId.PICKLE_DESERIALIZATION)
|
||||
assert mask & (1 << p.RuleId.EVAL_INJECTION)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanContent:
|
||||
def test_pickle_load_in_py_warns(self):
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content(
|
||||
"/tmp/foo.py", "import pickle\nx = pickle.load(open('p.pkl', 'rb'))\n"
|
||||
)
|
||||
names = [n for n, _ in findings]
|
||||
assert "pickle_deserialization" in names
|
||||
|
||||
def test_pickle_load_in_md_skipped_by_path_filter(self):
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content(
|
||||
"/tmp/foo.md", "import pickle\nx = pickle.load(open('p.pkl', 'rb'))\n"
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
def test_method_call_eval_does_not_trip(self):
|
||||
"""model.eval() / redis.eval() / spec.eval() must not match eval_injection."""
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content("/tmp/foo.py", "model.eval()\nout = model(x)\n")
|
||||
assert "eval_injection" not in [n for n, _ in findings]
|
||||
|
||||
def test_bare_eval_in_py_warns(self):
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content("/tmp/foo.py", "result = eval(user_input)\n")
|
||||
assert "eval_injection" in [n for n, _ in findings]
|
||||
|
||||
def test_subprocess_shell_true_warns(self):
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content(
|
||||
"/tmp/foo.py", "subprocess.run('ls ' + path, shell=True)\n"
|
||||
)
|
||||
assert "python_subprocess_shell" in [n for n, _ in findings]
|
||||
|
||||
def test_dangerously_set_inner_html_warns(self):
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content(
|
||||
"/tmp/foo.tsx", "<div dangerouslySetInnerHTML={{__html: x}} />"
|
||||
)
|
||||
assert "react_dangerously_set_html" in [n for n, _ in findings]
|
||||
|
||||
def test_github_workflow_path_check_fires_on_path_alone(self):
|
||||
"""github_actions_workflow has no regex/substring — fires on path."""
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content(
|
||||
".github/workflows/test.yml", "name: CI\non: pull_request"
|
||||
)
|
||||
assert "github_actions_workflow" in [n for n, _ in findings]
|
||||
|
||||
def test_non_workflow_path_doesnt_trip_workflow_rule(self):
|
||||
mod = _load_plugin_init()
|
||||
findings = mod._scan_content("/tmp/foo.py", "name: CI")
|
||||
assert "github_actions_workflow" not in [n for n, _ in findings]
|
||||
|
||||
def test_empty_content_returns_no_findings(self):
|
||||
mod = _load_plugin_init()
|
||||
assert mod._scan_content("/tmp/foo.py", "") == []
|
||||
|
||||
def test_huge_content_skipped(self):
|
||||
mod = _load_plugin_init()
|
||||
# 1 MB of content with a dangerous pattern at the end — scanner caps
|
||||
# out at _MAX_SCAN_BYTES (256 KB), so this should return [].
|
||||
big = "x" * (1024 * 1024) + "\npickle.load(open('p.pkl', 'rb'))\n"
|
||||
assert mod._scan_content("/tmp/foo.py", big) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransformToolResultHook:
|
||||
def test_warns_on_write_file_with_dangerous_content(self):
|
||||
mod = _load_plugin_init()
|
||||
args = {
|
||||
"path": "/tmp/foo.py",
|
||||
"content": "import pickle\nx = pickle.loads(b)\n",
|
||||
}
|
||||
result = mod._on_transform_tool_result(
|
||||
tool_name="write_file",
|
||||
args=args,
|
||||
result='{"success": true, "bytes_written": 30}',
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
assert "Security guidance" in result
|
||||
assert "pickle_deserialization" in result
|
||||
# The original JSON should still be there at the start of the string.
|
||||
assert result.startswith('{"success": true')
|
||||
|
||||
def test_no_warn_on_clean_content(self):
|
||||
mod = _load_plugin_init()
|
||||
args = {"path": "/tmp/foo.py", "content": "import json\nx = json.loads(b)\n"}
|
||||
assert (
|
||||
mod._on_transform_tool_result(
|
||||
tool_name="write_file", args=args, result='{"success": true}'
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_no_warn_when_result_is_error(self):
|
||||
mod = _load_plugin_init()
|
||||
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
||||
# When the tool itself errored, we don't pile a security warning on
|
||||
# top — the model has bigger problems to solve.
|
||||
assert (
|
||||
mod._on_transform_tool_result(
|
||||
tool_name="write_file", args=args, result='{"error": "boom"}'
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_patch_tool_new_string_scanned(self):
|
||||
mod = _load_plugin_init()
|
||||
args = {
|
||||
"path": "/tmp/foo.py",
|
||||
"old_string": "x = 1",
|
||||
"new_string": "x = eval(user_input)",
|
||||
}
|
||||
result = mod._on_transform_tool_result(
|
||||
tool_name="patch", args=args, result='{"success": true}'
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
assert "eval_injection" in result
|
||||
|
||||
def test_untargeted_tool_skipped(self):
|
||||
mod = _load_plugin_init()
|
||||
# The plugin only scans write_file/patch/skill_manage. terminal output
|
||||
# should pass through untouched.
|
||||
args = {"command": "echo pickle.load"}
|
||||
assert (
|
||||
mod._on_transform_tool_result(
|
||||
tool_name="terminal", args=args, result='{"output": "pickle.load"}'
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_disable_kill_switch(self, monkeypatch):
|
||||
mod = _load_plugin_init()
|
||||
monkeypatch.setenv("SECURITY_GUIDANCE_DISABLE", "1")
|
||||
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
||||
assert (
|
||||
mod._on_transform_tool_result(
|
||||
tool_name="write_file", args=args, result='{"ok": true}'
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_block_mode_makes_transform_hook_quiet(self, monkeypatch):
|
||||
"""In block mode, pre_tool_call handles the warning; the transform
|
||||
hook stays silent so we don't double-emit."""
|
||||
mod = _load_plugin_init()
|
||||
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
|
||||
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
||||
assert (
|
||||
mod._on_transform_tool_result(
|
||||
tool_name="write_file", args=args, result='{"ok": true}'
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestPreToolCallHook:
|
||||
def test_no_block_in_warn_mode(self):
|
||||
mod = _load_plugin_init()
|
||||
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
||||
assert mod._on_pre_tool_call(tool_name="write_file", args=args) is None
|
||||
|
||||
def test_blocks_in_block_mode_on_dangerous_pattern(self, monkeypatch):
|
||||
mod = _load_plugin_init()
|
||||
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
|
||||
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
||||
out = mod._on_pre_tool_call(tool_name="write_file", args=args)
|
||||
assert isinstance(out, dict)
|
||||
assert out["action"] == "block"
|
||||
assert "pickle_deserialization" in out["message"]
|
||||
assert "SECURITY_GUIDANCE_BLOCK" in out["message"] # tells user how to disable
|
||||
|
||||
def test_no_block_in_block_mode_on_clean_content(self, monkeypatch):
|
||||
mod = _load_plugin_init()
|
||||
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
|
||||
args = {"path": "/tmp/foo.py", "content": "import json\n"}
|
||||
assert mod._on_pre_tool_call(tool_name="write_file", args=args) is None
|
||||
|
||||
def test_untargeted_tool_skipped(self, monkeypatch):
|
||||
mod = _load_plugin_init()
|
||||
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
|
||||
args = {"command": "echo pickle.load(f)"}
|
||||
assert mod._on_pre_tool_call(tool_name="terminal", args=args) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled-plugin discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPluginDiscovery:
|
||||
def test_loads_via_plugin_manager(self, _isolate_env, monkeypatch):
|
||||
"""End-to-end: enable in config.yaml and verify the PluginManager
|
||||
picks it up via the standard discovery path."""
|
||||
import yaml
|
||||
|
||||
config = {"plugins": {"enabled": ["security-guidance"]}}
|
||||
(_isolate_env / "config.yaml").write_text(yaml.safe_dump(config))
|
||||
|
||||
# Wipe any cached plugin state from earlier tests in this worker.
|
||||
for k in list(sys.modules):
|
||||
if k.startswith(("hermes_plugins", "hermes_cli.plugins")):
|
||||
del sys.modules[k]
|
||||
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
mgr = _ensure_plugins_discovered(force=True)
|
||||
loaded = set()
|
||||
if hasattr(mgr, "_plugins"):
|
||||
loaded = set(mgr._plugins.keys())
|
||||
assert "security-guidance" in loaded
|
||||
@@ -0,0 +1,575 @@
|
||||
"""Tests for the Teams pipeline plugin package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from plugins.teams_pipeline import register
|
||||
from plugins.teams_pipeline.pipeline import TeamsMeetingPipeline
|
||||
from plugins.teams_pipeline.store import TeamsPipelineStore
|
||||
from plugins.teams_pipeline.models import MeetingArtifact
|
||||
|
||||
|
||||
class FakeGraphClient:
|
||||
def __init__(self) -> None:
|
||||
self.downloaded = False
|
||||
|
||||
|
||||
async def _transcript_meeting_resolver(client, *, meeting_id=None, join_web_url=None, tenant_id=None):
|
||||
from plugins.teams_pipeline.models import TeamsMeetingRef
|
||||
|
||||
return TeamsMeetingRef(
|
||||
meeting_id=str(meeting_id),
|
||||
tenant_id=tenant_id,
|
||||
metadata={"subject": "Weekly Sync", "participants": [{"displayName": "Ada"}]},
|
||||
)
|
||||
|
||||
|
||||
async def _no_call_record(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def test_register_adds_cli_only():
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="teams_pipeline")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
register(ctx)
|
||||
|
||||
assert "teams-pipeline" in mgr._cli_commands
|
||||
entry = mgr._cli_commands["teams-pipeline"]
|
||||
assert entry["plugin"] == "teams_pipeline"
|
||||
assert callable(entry["setup_fn"])
|
||||
assert callable(entry["handler_fn"])
|
||||
|
||||
|
||||
def test_runtime_config_uses_existing_teams_platform_settings():
|
||||
from plugins.teams_pipeline.runtime import build_pipeline_runtime_config
|
||||
|
||||
gateway_config = GatewayConfig(
|
||||
platforms={
|
||||
Platform("teams"): PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"delivery_mode": "graph",
|
||||
"team_id": "team-1",
|
||||
"channel_id": "channel-1",
|
||||
"meeting_pipeline": {
|
||||
"transcript_min_chars": 120,
|
||||
"notion": {"enabled": True, "database_id": "db-1"},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
runtime_config = build_pipeline_runtime_config(gateway_config)
|
||||
|
||||
assert runtime_config["transcript_min_chars"] == 120
|
||||
assert runtime_config["notion"]["database_id"] == "db-1"
|
||||
assert runtime_config["teams_delivery"] == {
|
||||
"enabled": True,
|
||||
"mode": "graph",
|
||||
"team_id": "team-1",
|
||||
"channel_id": "channel-1",
|
||||
}
|
||||
|
||||
|
||||
def test_build_pipeline_runtime_reuses_existing_teams_adapter_surface(monkeypatch, tmp_path):
|
||||
from plugins.teams_pipeline import runtime as runtime_module
|
||||
|
||||
class FakeWriter:
|
||||
def __init__(self, platform_config=None, **kwargs) -> None:
|
||||
self.platform_config = platform_config
|
||||
|
||||
monkeypatch.setattr(runtime_module, "build_graph_client", lambda: object())
|
||||
monkeypatch.setattr(runtime_module, "resolve_teams_pipeline_store_path", lambda: tmp_path / "teams-store.json")
|
||||
monkeypatch.setattr("plugins.platforms.teams.adapter.TeamsSummaryWriter", FakeWriter)
|
||||
|
||||
gateway = SimpleNamespace(
|
||||
config=GatewayConfig(
|
||||
platforms={
|
||||
Platform("teams"): PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"delivery_mode": "incoming_webhook",
|
||||
"incoming_webhook_url": "https://example.com/hook",
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
runtime = runtime_module.build_pipeline_runtime(gateway)
|
||||
|
||||
assert isinstance(runtime.teams_sender, FakeWriter)
|
||||
assert runtime.teams_sender.platform_config is gateway.config.platforms[Platform("teams")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bind_gateway_runtime_attaches_scheduler(monkeypatch, tmp_path):
|
||||
from plugins.teams_pipeline import runtime as runtime_module
|
||||
|
||||
class FakeAdapter:
|
||||
def __init__(self) -> None:
|
||||
self.scheduler = None
|
||||
|
||||
def set_notification_scheduler(self, scheduler) -> None:
|
||||
self.scheduler = scheduler
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self) -> None:
|
||||
self.notifications = []
|
||||
|
||||
async def run_notification(self, notification):
|
||||
self.notifications.append(notification)
|
||||
|
||||
adapter = FakeAdapter()
|
||||
pipeline = FakePipeline()
|
||||
gateway = SimpleNamespace(
|
||||
adapters={Platform.MSGRAPH_WEBHOOK: adapter},
|
||||
config=GatewayConfig(platforms={}),
|
||||
_teams_pipeline_runtime=None,
|
||||
_teams_pipeline_runtime_error=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runtime_module, "build_pipeline_runtime", lambda gateway_runner: pipeline)
|
||||
|
||||
bound = runtime_module.bind_gateway_runtime(gateway)
|
||||
|
||||
assert bound is True
|
||||
assert gateway._teams_pipeline_runtime is pipeline
|
||||
assert callable(adapter.scheduler)
|
||||
|
||||
notification = {"id": "notif-1"}
|
||||
await adapter.scheduler(notification, object())
|
||||
assert pipeline.notifications == [notification]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bind_gateway_runtime_drops_notifications_when_unavailable(monkeypatch):
|
||||
from plugins.teams_pipeline import runtime as runtime_module
|
||||
from tools.microsoft_graph_auth import MicrosoftGraphConfigError
|
||||
|
||||
class FakeAdapter:
|
||||
def __init__(self) -> None:
|
||||
self.scheduler = None
|
||||
|
||||
def set_notification_scheduler(self, scheduler) -> None:
|
||||
self.scheduler = scheduler
|
||||
|
||||
adapter = FakeAdapter()
|
||||
gateway = SimpleNamespace(
|
||||
adapters={Platform.MSGRAPH_WEBHOOK: adapter},
|
||||
config=GatewayConfig(platforms={}),
|
||||
_teams_pipeline_runtime=None,
|
||||
_teams_pipeline_runtime_error=None,
|
||||
)
|
||||
|
||||
def _raise(_gateway_runner):
|
||||
raise MicrosoftGraphConfigError("missing graph env")
|
||||
|
||||
monkeypatch.setattr(runtime_module, "build_pipeline_runtime", _raise)
|
||||
|
||||
bound = runtime_module.bind_gateway_runtime(gateway)
|
||||
|
||||
assert bound is False
|
||||
assert "missing graph env" in gateway._teams_pipeline_runtime_error
|
||||
assert callable(adapter.scheduler)
|
||||
await adapter.scheduler({"id": "notif-2"}, object())
|
||||
|
||||
|
||||
def test_store_persists_subscription_event_and_job_state(tmp_path):
|
||||
store_path = tmp_path / "teams-store.json"
|
||||
store = TeamsPipelineStore(store_path)
|
||||
store.upsert_subscription(
|
||||
"sub-1",
|
||||
{"client_state": "abc", "resource": "communications/onlineMeetings"},
|
||||
)
|
||||
store.record_event_timestamp("evt-1", "2026-05-03T19:30:00Z")
|
||||
store.upsert_job("job-1", {"status": "received", "event_id": "evt-1"})
|
||||
store.upsert_sink_record("notion:meeting-1", {"page_id": "page-1"})
|
||||
|
||||
reloaded = TeamsPipelineStore(store_path)
|
||||
subscription = reloaded.get_subscription("sub-1")
|
||||
job = reloaded.get_job("job-1")
|
||||
sink = reloaded.get_sink_record("notion:meeting-1")
|
||||
|
||||
assert subscription is not None
|
||||
assert subscription["subscription_id"] == "sub-1"
|
||||
assert subscription["client_state"] == "abc"
|
||||
assert reloaded.get_event_timestamp("evt-1") == "2026-05-03T19:30:00Z"
|
||||
assert job is not None
|
||||
assert job["status"] == "received"
|
||||
assert sink is not None
|
||||
assert sink["page_id"] == "page-1"
|
||||
|
||||
|
||||
def test_store_notification_receipts_are_idempotent(tmp_path):
|
||||
store = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
notification = {
|
||||
"subscriptionId": "sub-1",
|
||||
"resource": "communications/onlineMeetings/meeting-1",
|
||||
"changeType": "updated",
|
||||
}
|
||||
receipt_key = TeamsPipelineStore.build_notification_receipt_key(notification)
|
||||
|
||||
assert store.record_notification_receipt(receipt_key, notification) is True
|
||||
assert store.record_notification_receipt(receipt_key, notification) is False
|
||||
assert store.has_notification_receipt(receipt_key) is True
|
||||
|
||||
reloaded = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
assert reloaded.has_notification_receipt(receipt_key) is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestTeamsMeetingPipeline:
|
||||
async def test_transcript_first_path_persists_state_and_skips_recording(self, tmp_path, monkeypatch):
|
||||
from plugins.teams_pipeline import pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
|
||||
|
||||
async def _fetch_transcript(client, meeting_ref):
|
||||
return (
|
||||
MeetingArtifact(artifact_type="transcript", artifact_id="tx-1", display_name="meeting.vtt"),
|
||||
"Action: Send draft by Friday.\nDecision: Ship the transcript-first path.\nDetailed transcript content.",
|
||||
)
|
||||
|
||||
async def _call_record(client, meeting_ref, *, call_record_id=None, allow_permission_errors=True):
|
||||
return MeetingArtifact(
|
||||
artifact_type="call_record",
|
||||
artifact_id="call-1",
|
||||
metadata={"metrics": {"participant_count": 4}},
|
||||
)
|
||||
|
||||
async def _summarize(**kwargs):
|
||||
return pipeline_module.TeamsMeetingSummaryPayload(
|
||||
meeting_ref=kwargs["resolved_meeting"],
|
||||
title="Weekly Sync",
|
||||
transcript_text=kwargs["transcript_text"],
|
||||
summary="Short summary",
|
||||
key_decisions=["Ship the transcript-first path."],
|
||||
action_items=["Send draft by Friday."],
|
||||
risks=["Timeline risk."],
|
||||
confidence="high",
|
||||
confidence_notes="Transcript available.",
|
||||
source_artifacts=kwargs["artifacts"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _fetch_transcript)
|
||||
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _call_record)
|
||||
|
||||
store = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
pipeline = TeamsMeetingPipeline(
|
||||
graph_client=FakeGraphClient(),
|
||||
store=store,
|
||||
config={"transcript_min_chars": 20},
|
||||
summarize_fn=_summarize,
|
||||
)
|
||||
|
||||
job = await pipeline.run_notification(
|
||||
{
|
||||
"id": "notif-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-123",
|
||||
"resourceData": {"id": "meeting-123"},
|
||||
}
|
||||
)
|
||||
|
||||
assert job.status == "completed"
|
||||
assert job.selected_artifact_strategy == "transcript_first"
|
||||
assert job.summary_payload is not None
|
||||
assert job.summary_payload.summary == "Short summary"
|
||||
stored = store.get_job(job.job_id)
|
||||
assert stored is not None
|
||||
assert stored["status"] == "completed"
|
||||
|
||||
async def test_recording_fallback_uses_stt_and_updates_sink_records(self, tmp_path, monkeypatch):
|
||||
from plugins.teams_pipeline import pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
|
||||
|
||||
async def _no_transcript(client, meeting_ref):
|
||||
return None, None
|
||||
|
||||
async def _recordings(client, meeting_ref):
|
||||
return [
|
||||
MeetingArtifact(
|
||||
artifact_type="recording",
|
||||
artifact_id="rec-1",
|
||||
display_name="../../nested/recording.mp4",
|
||||
download_url="https://files.example/recording.mp4",
|
||||
)
|
||||
]
|
||||
|
||||
downloaded_targets = []
|
||||
|
||||
async def _download(client, meeting_ref, recording, destination):
|
||||
target = Path(destination)
|
||||
downloaded_targets.append(target)
|
||||
target.write_bytes(b"video-bytes")
|
||||
return {"path": str(target), "size_bytes": 11, "content_type": "video/mp4"}
|
||||
|
||||
async def _prepare_audio(self, recording_path):
|
||||
audio_path = recording_path.with_suffix(".wav")
|
||||
audio_path.write_bytes(b"audio-bytes")
|
||||
return audio_path
|
||||
|
||||
def _transcribe(file_path, model):
|
||||
return {"success": True, "transcript": "Action: Follow up with Legal.\nRisk: Budget approval pending.", "provider": "local"}
|
||||
|
||||
async def _summarize(**kwargs):
|
||||
return pipeline_module.TeamsMeetingSummaryPayload(
|
||||
meeting_ref=kwargs["resolved_meeting"],
|
||||
title="Weekly Sync",
|
||||
transcript_text=kwargs["transcript_text"],
|
||||
summary="Fallback summary",
|
||||
key_decisions=[],
|
||||
action_items=["Follow up with Legal."],
|
||||
risks=["Budget approval pending."],
|
||||
confidence="medium",
|
||||
confidence_notes="Generated from STT fallback.",
|
||||
source_artifacts=kwargs["artifacts"],
|
||||
)
|
||||
|
||||
class FakeNotionWriter:
|
||||
async def write_summary(self, payload, config, existing_record=None):
|
||||
return {"page_id": existing_record.get("page_id") if existing_record else "page-1", "url": "https://notion.so/page-1"}
|
||||
|
||||
async def _teams_sender(payload, config, existing_record=None):
|
||||
return {"message_id": existing_record.get("message_id") if existing_record else "msg-1"}
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _no_transcript)
|
||||
monkeypatch.setattr(pipeline_module, "list_recording_artifacts", _recordings)
|
||||
monkeypatch.setattr(pipeline_module, "download_recording_artifact", _download)
|
||||
monkeypatch.setattr(pipeline_module.TeamsMeetingPipeline, "_prepare_audio_path", _prepare_audio)
|
||||
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _no_call_record)
|
||||
|
||||
store = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
pipeline = TeamsMeetingPipeline(
|
||||
graph_client=FakeGraphClient(),
|
||||
store=store,
|
||||
config={
|
||||
"notion": {"enabled": True, "database_id": "db-1"},
|
||||
"teams_delivery": {"enabled": True, "channel_id": "channel-1"},
|
||||
},
|
||||
transcribe_fn=_transcribe,
|
||||
summarize_fn=_summarize,
|
||||
notion_writer=FakeNotionWriter(),
|
||||
teams_sender=_teams_sender,
|
||||
)
|
||||
|
||||
job = await pipeline.run_notification(
|
||||
{
|
||||
"id": "notif-2",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-456",
|
||||
"resourceData": {"id": "meeting-456"},
|
||||
}
|
||||
)
|
||||
|
||||
assert job.status == "completed"
|
||||
assert job.selected_artifact_strategy == "recording_stt_fallback"
|
||||
assert job.summary_payload is not None
|
||||
assert job.summary_payload.summary == "Fallback summary"
|
||||
assert downloaded_targets
|
||||
assert downloaded_targets[0].name == "recording.mp4"
|
||||
assert "nested" not in str(downloaded_targets[0])
|
||||
notion_record = store.get_sink_record("notion:meeting-456")
|
||||
teams_record = store.get_sink_record("teams:meeting-456")
|
||||
assert notion_record is not None
|
||||
assert notion_record["page_id"] == "page-1"
|
||||
assert teams_record is not None
|
||||
assert teams_record["message_id"] == "msg-1"
|
||||
|
||||
@pytest.mark.parametrize("crafted_name", ["..", "../", ".", ""])
|
||||
async def test_recording_dot_only_display_name_falls_back_to_artifact_id(
|
||||
self, tmp_path, monkeypatch, crafted_name
|
||||
):
|
||||
# Path("..").name == ".." and Path(".").name == "" — so basename
|
||||
# extraction alone does not neutralize dot-only names. Joining
|
||||
# tmp_dir / ".." resolves to the parent directory (an escape), so
|
||||
# the pipeline must reject these and fall back to the artifact id.
|
||||
from plugins.teams_pipeline import pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
|
||||
|
||||
async def _no_transcript(client, meeting_ref):
|
||||
return None, None
|
||||
|
||||
async def _recordings(client, meeting_ref):
|
||||
return [
|
||||
MeetingArtifact(
|
||||
artifact_type="recording",
|
||||
artifact_id="rec-dot",
|
||||
display_name=crafted_name,
|
||||
download_url="https://files.example/recording.mp4",
|
||||
)
|
||||
]
|
||||
|
||||
downloaded_targets = []
|
||||
|
||||
async def _download(client, meeting_ref, recording, destination):
|
||||
target = Path(destination)
|
||||
downloaded_targets.append(target)
|
||||
target.write_bytes(b"video-bytes")
|
||||
return {"path": str(target), "size_bytes": 11, "content_type": "video/mp4"}
|
||||
|
||||
async def _prepare_audio(self, recording_path):
|
||||
audio_path = recording_path.with_suffix(".wav")
|
||||
audio_path.write_bytes(b"audio-bytes")
|
||||
return audio_path
|
||||
|
||||
def _transcribe(file_path, model):
|
||||
return {"success": True, "transcript": "Action: Follow up.", "provider": "local"}
|
||||
|
||||
async def _summarize(**kwargs):
|
||||
return pipeline_module.TeamsMeetingSummaryPayload(
|
||||
meeting_ref=kwargs["resolved_meeting"],
|
||||
title="Weekly Sync",
|
||||
transcript_text=kwargs["transcript_text"],
|
||||
summary="Fallback summary",
|
||||
key_decisions=[],
|
||||
action_items=["Follow up."],
|
||||
risks=[],
|
||||
confidence="medium",
|
||||
confidence_notes="Generated from STT fallback.",
|
||||
source_artifacts=kwargs["artifacts"],
|
||||
)
|
||||
|
||||
class FakeNotionWriter:
|
||||
async def write_summary(self, payload, config, existing_record=None):
|
||||
return {"page_id": "page-1", "url": "https://notion.so/page-1"}
|
||||
|
||||
async def _teams_sender(payload, config, existing_record=None):
|
||||
return {"message_id": "msg-1"}
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _no_transcript)
|
||||
monkeypatch.setattr(pipeline_module, "list_recording_artifacts", _recordings)
|
||||
monkeypatch.setattr(pipeline_module, "download_recording_artifact", _download)
|
||||
monkeypatch.setattr(pipeline_module.TeamsMeetingPipeline, "_prepare_audio_path", _prepare_audio)
|
||||
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _no_call_record)
|
||||
|
||||
temp_root = tmp_path / "teams-tmp"
|
||||
store = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
pipeline = TeamsMeetingPipeline(
|
||||
graph_client=FakeGraphClient(),
|
||||
store=store,
|
||||
config={
|
||||
"tmp_dir": str(temp_root),
|
||||
"notion": {"enabled": True, "database_id": "db-1"},
|
||||
"teams_delivery": {"enabled": True, "channel_id": "channel-1"},
|
||||
},
|
||||
transcribe_fn=_transcribe,
|
||||
summarize_fn=_summarize,
|
||||
notion_writer=FakeNotionWriter(),
|
||||
teams_sender=_teams_sender,
|
||||
)
|
||||
|
||||
job = await pipeline.run_notification(
|
||||
{
|
||||
"id": "notif-dot",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-dot",
|
||||
"resourceData": {"id": "meeting-dot"},
|
||||
}
|
||||
)
|
||||
|
||||
assert job.status == "completed"
|
||||
assert downloaded_targets
|
||||
target = downloaded_targets[0]
|
||||
# Fell back to the artifact id, not the crafted dot-only name.
|
||||
assert target.name == "rec-dot.mp4"
|
||||
# Stayed inside the generated temp recording directory (no escape).
|
||||
assert target.resolve().parent.parent == temp_root.resolve()
|
||||
assert target.resolve().parent.name.startswith("teams-recording-")
|
||||
|
||||
async def test_missing_transcript_and_recording_schedules_retry(self, tmp_path, monkeypatch):
|
||||
from plugins.teams_pipeline import pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
|
||||
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", lambda *a, **kw: asyncio.sleep(0, result=(None, None)))
|
||||
monkeypatch.setattr(pipeline_module, "list_recording_artifacts", lambda *a, **kw: asyncio.sleep(0, result=[]))
|
||||
|
||||
store = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
pipeline = TeamsMeetingPipeline(
|
||||
graph_client=FakeGraphClient(),
|
||||
store=store,
|
||||
config={},
|
||||
summarize_fn=lambda **kwargs: asyncio.sleep(0, result=None),
|
||||
)
|
||||
|
||||
job = await pipeline.run_notification(
|
||||
{
|
||||
"id": "notif-3",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-789",
|
||||
"resourceData": {"id": "meeting-789"},
|
||||
}
|
||||
)
|
||||
|
||||
assert job.status == "retry_scheduled"
|
||||
assert job.error_info["retryable"] is True
|
||||
assert "Recording unavailable" in job.error_info["message"]
|
||||
|
||||
async def test_duplicate_notification_reuses_completed_job(self, tmp_path, monkeypatch):
|
||||
from plugins.teams_pipeline import pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "resolve_meeting_reference", _transcript_meeting_resolver)
|
||||
|
||||
async def _fetch_transcript(client, meeting_ref):
|
||||
return (
|
||||
MeetingArtifact(artifact_type="transcript", artifact_id="tx-dup", display_name="meeting.vtt"),
|
||||
"Decision: Keep duplicate notifications idempotent.\nAction: Verify the cached job is reused.",
|
||||
)
|
||||
|
||||
summarize_calls = 0
|
||||
|
||||
async def _summarize(**kwargs):
|
||||
nonlocal summarize_calls
|
||||
summarize_calls += 1
|
||||
return pipeline_module.TeamsMeetingSummaryPayload(
|
||||
meeting_ref=kwargs["resolved_meeting"],
|
||||
title="Weekly Sync",
|
||||
transcript_text=kwargs["transcript_text"],
|
||||
summary="Duplicate-safe summary",
|
||||
key_decisions=["Keep duplicate notifications idempotent."],
|
||||
action_items=["Verify the cached job is reused."],
|
||||
confidence="high",
|
||||
confidence_notes="Transcript available.",
|
||||
source_artifacts=kwargs["artifacts"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "fetch_preferred_transcript_text", _fetch_transcript)
|
||||
monkeypatch.setattr(pipeline_module, "enrich_meeting_with_call_record", _no_call_record)
|
||||
|
||||
store = TeamsPipelineStore(tmp_path / "teams-store.json")
|
||||
pipeline = TeamsMeetingPipeline(
|
||||
graph_client=FakeGraphClient(),
|
||||
store=store,
|
||||
config={"transcript_min_chars": 20},
|
||||
summarize_fn=_summarize,
|
||||
)
|
||||
notification = {
|
||||
"id": "notif-dup",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-dup",
|
||||
"resourceData": {"id": "meeting-dup"},
|
||||
}
|
||||
|
||||
first_job = await pipeline.run_notification(notification)
|
||||
second_job = await pipeline.run_notification(notification)
|
||||
|
||||
assert first_job.status == "completed"
|
||||
assert second_job.status == "completed"
|
||||
assert second_job.job_id == first_job.job_id
|
||||
assert summarize_calls == 1
|
||||
assert len(store.list_jobs()) == 1
|
||||
receipt_key = TeamsPipelineStore.build_notification_receipt_key(notification)
|
||||
assert store.has_notification_receipt(receipt_key) is True
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Behavior-parity check for the STT plugin hook + command-provider registry.
|
||||
|
||||
Spawns one subprocess per (version, scenario) cell — pinned to either
|
||||
``origin/main`` (no plugin hook, no STT command-provider registry; only
|
||||
the legacy ``HERMES_LOCAL_STT_COMMAND`` escape hatch exists) or this PR's
|
||||
worktree (both new surfaces present).
|
||||
|
||||
Each subprocess clears all STT-related env vars + writes a
|
||||
``config.yaml``, then asks the dispatcher how it would route a
|
||||
``transcribe_audio`` call. The emitted shape tuple is::
|
||||
|
||||
{dispatch_kind, provider_name, success}
|
||||
|
||||
Where ``dispatch_kind`` ∈
|
||||
``{"builtin_local", "builtin_groq", "builtin_openai", ...,
|
||||
"plugin", "plugin_unavailable", "command_provider",
|
||||
"no_provider_error", "stt_disabled"}``.
|
||||
|
||||
Acceptable diffs:
|
||||
- ``no_provider_error → plugin`` for the ``plugin-installed`` scenario.
|
||||
- ``no_provider_error → plugin_unavailable`` for the
|
||||
``plugin-installed-unavailable`` scenario (PR returns the cleaner
|
||||
unavailability envelope instead of the generic auto-detect error).
|
||||
- ``no_provider_error → command_provider`` for the
|
||||
``command-provider-installed`` scenario (registry shipped with this PR).
|
||||
- ``no_provider_error → command_provider`` for
|
||||
``command-vs-plugin-same-name`` (command wins precedence, same as TTS).
|
||||
|
||||
Run from the PR worktree::
|
||||
|
||||
python tests/plugins/transcription/check_parity_vs_main.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _resolve_main_dir() -> Path:
|
||||
candidate = REPO_ROOT.parent.parent
|
||||
if (candidate / "tools" / "transcription_tools.py").exists() and candidate != REPO_ROOT:
|
||||
return candidate
|
||||
sibling = REPO_ROOT.parent / "hermes-agent-main"
|
||||
if (sibling / "tools" / "transcription_tools.py").exists():
|
||||
return sibling
|
||||
return REPO_ROOT
|
||||
|
||||
|
||||
MAIN_DIR = _resolve_main_dir()
|
||||
PR_DIR = REPO_ROOT
|
||||
assert (PR_DIR / "tools" / "transcription_tools.py").exists(), (
|
||||
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
|
||||
)
|
||||
|
||||
|
||||
SUBPROCESS_SCRIPT = r"""
|
||||
import json, os, sys, tempfile
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
# Isolated HERMES_HOME so the config write is hermetic.
|
||||
home = tempfile.mkdtemp()
|
||||
os.environ["HERMES_HOME"] = home
|
||||
|
||||
# Clear STT-related env so dispatch decisions are config-driven.
|
||||
for k in (
|
||||
"GROQ_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY",
|
||||
"MISTRAL_API_KEY", "XAI_API_KEY",
|
||||
"HERMES_LOCAL_STT_COMMAND",
|
||||
):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
scenario_env = json.loads(sys.argv[2])
|
||||
os.environ.update(scenario_env)
|
||||
|
||||
config_yaml = sys.argv[3]
|
||||
plugin_register = sys.argv[4] # "yes" to register a fake plugin
|
||||
|
||||
config_path = os.path.join(home, "config.yaml")
|
||||
with open(config_path, "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
# Fresh import — must not have anything cached from prior runs.
|
||||
for name in list(sys.modules):
|
||||
if (name.startswith("tools.")
|
||||
or name.startswith("agent.")
|
||||
or name.startswith("plugins.")
|
||||
or name.startswith("hermes_cli.")):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
# Try importing transcription_registry — only exists on PR side.
|
||||
have_plugin_hook = False
|
||||
try:
|
||||
from agent import transcription_registry
|
||||
from agent.transcription_provider import TranscriptionProvider
|
||||
have_plugin_hook = True
|
||||
|
||||
if plugin_register == "yes":
|
||||
class _FakeProvider(TranscriptionProvider):
|
||||
@property
|
||||
def name(self): return "openrouter"
|
||||
def transcribe(self, file_path, **kw):
|
||||
return {"success": True, "transcript": "PLUGIN: openrouter transcript", "provider": "openrouter"}
|
||||
|
||||
transcription_registry._reset_for_tests()
|
||||
transcription_registry.register_provider(_FakeProvider())
|
||||
elif plugin_register == "unavailable":
|
||||
class _UnavailablePlugin(TranscriptionProvider):
|
||||
@property
|
||||
def name(self): return "openrouter"
|
||||
def is_available(self): return False
|
||||
def transcribe(self, file_path, **kw):
|
||||
return {"success": True, "transcript": "should not run"}
|
||||
|
||||
transcription_registry._reset_for_tests()
|
||||
transcription_registry.register_provider(_UnavailablePlugin())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import tools.transcription_tools as tt
|
||||
|
||||
# Use a real (but empty) audio file so _validate_audio_file passes.
|
||||
audio_path = os.path.join(home, "audio.ogg")
|
||||
with open(audio_path, "wb") as f:
|
||||
# Minimal-ish OGG-shaped bytes so the size check passes.
|
||||
f.write(b"OggS" + b"\x00" * 1024)
|
||||
|
||||
# Patch _transcribe_* so the test doesn't actually try cloud APIs.
|
||||
# We're testing dispatch, not the underlying transcription.
|
||||
def _stub(file_path, model_name=None):
|
||||
return {"success": True, "transcript": "stub from " + sys._getframe().f_code.co_name.replace("_stub_", ""),
|
||||
"provider": sys._getframe().f_code.co_name.replace("_stub_", "")}
|
||||
|
||||
# Stub each built-in to a marker so we can identify the branch.
|
||||
class _Stub:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
def __call__(self, file_path, model_name=None):
|
||||
return {"success": True, "transcript": "stub", "provider": self.name}
|
||||
|
||||
tt._transcribe_local = _Stub("local")
|
||||
tt._transcribe_local_command = _Stub("local_command")
|
||||
tt._transcribe_groq = _Stub("groq")
|
||||
tt._transcribe_openai = _Stub("openai")
|
||||
tt._transcribe_mistral = _Stub("mistral")
|
||||
tt._transcribe_xai = _Stub("xai")
|
||||
|
||||
# Force _get_provider to honor the explicit config since we don't have
|
||||
# real creds. The provider-resolution gates check _HAS_OPENAI /
|
||||
# _HAS_FASTER_WHISPER which we can't easily set, so we just patch
|
||||
# _get_provider to return whatever the config says.
|
||||
stt_cfg = tt._load_stt_config()
|
||||
explicit = stt_cfg.get("provider")
|
||||
if explicit:
|
||||
# Bypass the gating for test purposes — _get_provider would
|
||||
# otherwise return "none" when the dependency isn't installed.
|
||||
original_get = tt._get_provider
|
||||
def _patched(cfg):
|
||||
if not tt.is_stt_enabled(cfg):
|
||||
return "none"
|
||||
return cfg.get("provider", "none")
|
||||
tt._get_provider = _patched
|
||||
|
||||
try:
|
||||
result = tt.transcribe_audio(audio_path)
|
||||
except Exception as exc:
|
||||
shape = {"dispatch_kind": "exception", "provider_name": None, "success": False,
|
||||
"error_text": repr(exc)}
|
||||
print(json.dumps(shape))
|
||||
sys.exit(0)
|
||||
|
||||
dispatch_kind = "unknown"
|
||||
provider_name = result.get("provider") if isinstance(result, dict) else None
|
||||
success = result.get("success", False) if isinstance(result, dict) else False
|
||||
error_text = result.get("error", "") if isinstance(result, dict) else ""
|
||||
|
||||
if not success and "STT is disabled" in error_text:
|
||||
dispatch_kind = "stt_disabled"
|
||||
elif not success and "is not available" in error_text:
|
||||
dispatch_kind = "plugin_unavailable"
|
||||
elif not success and "No STT provider" in error_text:
|
||||
dispatch_kind = "no_provider_error"
|
||||
elif provider_name in ("local", "local_command", "groq", "openai", "mistral", "xai"):
|
||||
dispatch_kind = "builtin_" + provider_name
|
||||
elif success and isinstance(result, dict) and result.get("transcript", "").startswith("CMD:"):
|
||||
# Command-provider scenarios below emit transcripts prefixed with "CMD:"
|
||||
# so the harness can distinguish command-provider dispatch from a
|
||||
# plugin dispatch even when they share a provider name.
|
||||
dispatch_kind = "command_provider"
|
||||
elif success and isinstance(result, dict) and result.get("transcript", "").startswith("PLUGIN:"):
|
||||
dispatch_kind = "plugin"
|
||||
elif success and provider_name and provider_name not in ("local", "local_command", "groq", "openai", "mistral", "xai"):
|
||||
dispatch_kind = "plugin"
|
||||
else:
|
||||
dispatch_kind = "other"
|
||||
|
||||
shape = {
|
||||
"dispatch_kind": dispatch_kind,
|
||||
"provider_name": provider_name,
|
||||
"success": success,
|
||||
}
|
||||
print(json.dumps(shape))
|
||||
"""
|
||||
|
||||
|
||||
def _cmd_yaml(provider_name: str, transcript: str) -> str:
|
||||
"""Build a YAML snippet for an stt.providers.<name>: type: command entry.
|
||||
|
||||
Produces a shell command that writes ``transcript`` to {output_path}.
|
||||
Backslashes in the venv python path are doubled for YAML, and the
|
||||
inner double quotes around the python -c payload are YAML-escaped.
|
||||
Keeps the test scenarios readable.
|
||||
"""
|
||||
interp = sys.executable.replace("\\", "\\\\")
|
||||
# Inside the YAML double-quoted string, we use single quotes around
|
||||
# the python -c body so we don't have to YAML-escape inner double
|
||||
# quotes. Single quotes inside the body are not needed; the body uses
|
||||
# double quotes for module references and string literals.
|
||||
payload = (
|
||||
f"import sys; open(sys.argv[1], 'w').write('{transcript}')"
|
||||
)
|
||||
command = f'{interp} -c "{payload}" {{output_path}}'
|
||||
# YAML-escape: double-quote the whole thing, escape inner " and \.
|
||||
yaml_escaped = command.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return (
|
||||
"stt:\n"
|
||||
f" provider: {provider_name}\n"
|
||||
" providers:\n"
|
||||
f" {provider_name}:\n"
|
||||
" type: command\n"
|
||||
f' command: "{yaml_escaped}"\n'
|
||||
)
|
||||
|
||||
|
||||
SCENARIOS: list[tuple[str, str, dict[str, str], str]] = [
|
||||
# (label, config.yaml body, scenario_env, plugin_register)
|
||||
("stt-disabled", "stt:\n enabled: false\n", {}, "no"),
|
||||
("explicit-groq", "stt:\n provider: groq\n", {}, "no"),
|
||||
("explicit-openai", "stt:\n provider: openai\n", {}, "no"),
|
||||
("explicit-local", "stt:\n provider: local\n", {}, "no"),
|
||||
("explicit-xai", "stt:\n provider: xai\n", {}, "no"),
|
||||
# Mistral is quarantined → _get_provider returns "none" today, hence no_provider_error.
|
||||
("explicit-mistral-quarantine", "stt:\n provider: mistral\n", {}, "no"),
|
||||
# Unknown name + no plugin → both: no_provider_error
|
||||
("unknown-no-plugin", "stt:\n provider: openrouter\n", {}, "no"),
|
||||
# Unknown name + plugin installed → main: no_provider_error, PR: plugin
|
||||
("plugin-installed", "stt:\n provider: openrouter\n", {}, "yes"),
|
||||
# Unknown name + plugin reports unavailable → main: no_provider_error,
|
||||
# PR: plugin_unavailable (cleaner envelope, names the plugin)
|
||||
("plugin-installed-unavailable", "stt:\n provider: openrouter\n", {}, "unavailable"),
|
||||
# Built-in name + plugin tries to shadow → both: built-in
|
||||
("explicit-openai-with-plugin-registered", "stt:\n provider: openai\n", {}, "yes"),
|
||||
# NEW (this PR): stt.providers.<name>: type: command registry.
|
||||
# Provider name "fake-cli" + transcript prefixed "CMD:" so dispatch_kind
|
||||
# detection routes it to "command_provider". On main (no registry),
|
||||
# this falls through to no_provider_error.
|
||||
(
|
||||
"command-provider-installed",
|
||||
_cmd_yaml("fake-cli", "CMD: fake-cli transcript"),
|
||||
{},
|
||||
"no",
|
||||
),
|
||||
# NEW (this PR): same name registered as BOTH a command provider and
|
||||
# a plugin under "openrouter". Command must win (config more local
|
||||
# than plugin install). The plugin emits "PLUGIN:..." — assertion is
|
||||
# that the transcript is "CMD:...", proving command-wins precedence.
|
||||
(
|
||||
"command-vs-plugin-same-name",
|
||||
_cmd_yaml("openrouter", "CMD: openrouter via command wins"),
|
||||
{},
|
||||
"yes", # also register a plugin under "openrouter" — must NOT fire
|
||||
),
|
||||
# NEW (this PR): built-in name with a command provider declared under
|
||||
# it → built-in still wins (built-in elif chain has precedence).
|
||||
# The command would write "CMD: HIJACK" if it fired — assertion is
|
||||
# that built-in OpenAI dispatch fires instead.
|
||||
(
|
||||
"explicit-openai-with-command-shadow",
|
||||
_cmd_yaml("openai", "CMD: HIJACK"),
|
||||
{},
|
||||
"no",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Subprocesses reset the registry between runs via ``_reset_for_tests`` so
|
||||
# registrations from earlier scenarios don't leak. The command-provider
|
||||
# scenarios also work on origin/main — the subprocess just executes the
|
||||
# native dispatch path, which falls through to "no_provider_error" because
|
||||
# main has no registry for stt.providers.<name>.
|
||||
|
||||
|
||||
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict, plugin_register: str) -> dict:
|
||||
venv_python = repo_path / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / "venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = Path("python3")
|
||||
|
||||
out = subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
SUBPROCESS_SCRIPT,
|
||||
str(repo_path),
|
||||
json.dumps(env),
|
||||
config_yaml,
|
||||
plugin_register,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return {
|
||||
"error": "subprocess failed",
|
||||
"stdout": out.stdout[-500:],
|
||||
"stderr": out.stderr[-500:],
|
||||
}
|
||||
try:
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
except Exception as exc:
|
||||
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
|
||||
|
||||
|
||||
def _reduce(shape: dict) -> dict:
|
||||
return {
|
||||
"dispatch_kind": shape.get("dispatch_kind"),
|
||||
"success": shape.get("success"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"main: {MAIN_DIR}")
|
||||
print(f"pr: {PR_DIR}")
|
||||
print()
|
||||
|
||||
if MAIN_DIR == PR_DIR:
|
||||
print(
|
||||
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
|
||||
" Set up a sibling 'hermes-agent-main' checkout pinned to "
|
||||
"origin/main to get real parity coverage."
|
||||
)
|
||||
print()
|
||||
|
||||
failures: list[str] = []
|
||||
errors: list[str] = []
|
||||
intentional_diffs: list[tuple[str, dict, dict]] = []
|
||||
for label, config_yaml, env, plugin_register in SCENARIOS:
|
||||
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env, plugin_register)
|
||||
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env, plugin_register)
|
||||
|
||||
if "error" in main_shape or "error" in pr_shape:
|
||||
print(f" [ERR ] {label}: subprocess failed")
|
||||
print(f" main: {main_shape}")
|
||||
print(f" pr: {pr_shape}")
|
||||
errors.append(label)
|
||||
continue
|
||||
|
||||
main_reduced = _reduce(main_shape)
|
||||
pr_reduced = _reduce(pr_shape)
|
||||
|
||||
if main_reduced == pr_reduced:
|
||||
print(f" [OK] {label}: {main_reduced}")
|
||||
continue
|
||||
|
||||
# On main, "plugin-installed" returns no_provider_error (no
|
||||
# plugin hook); on PR, plugin dispatches. Same shape for
|
||||
# "plugin-installed-unavailable" but PR returns the cleaner
|
||||
# plugin_unavailable envelope. The new command-provider scenarios
|
||||
# also intentionally diff against main (which has no stt.providers
|
||||
# registry yet).
|
||||
no_provider_to_plugin = (
|
||||
main_reduced.get("dispatch_kind") == "no_provider_error"
|
||||
and pr_reduced.get("dispatch_kind") == "plugin"
|
||||
and label == "plugin-installed"
|
||||
)
|
||||
no_provider_to_unavailable = (
|
||||
main_reduced.get("dispatch_kind") == "no_provider_error"
|
||||
and pr_reduced.get("dispatch_kind") == "plugin_unavailable"
|
||||
and label == "plugin-installed-unavailable"
|
||||
)
|
||||
no_provider_to_command = (
|
||||
main_reduced.get("dispatch_kind") == "no_provider_error"
|
||||
and pr_reduced.get("dispatch_kind") == "command_provider"
|
||||
and label in {"command-provider-installed", "command-vs-plugin-same-name"}
|
||||
)
|
||||
if no_provider_to_plugin:
|
||||
print(f" [DIFF] {label}: no_provider_error → plugin — expected")
|
||||
intentional_diffs.append((label, main_reduced, pr_reduced))
|
||||
elif no_provider_to_unavailable:
|
||||
print(f" [DIFF] {label}: no_provider_error → plugin_unavailable — expected")
|
||||
intentional_diffs.append((label, main_reduced, pr_reduced))
|
||||
elif no_provider_to_command:
|
||||
print(f" [DIFF] {label}: no_provider_error → command_provider — expected")
|
||||
intentional_diffs.append((label, main_reduced, pr_reduced))
|
||||
else:
|
||||
print(f" [FAIL] {label}")
|
||||
print(f" main: {main_reduced}")
|
||||
print(f" pr: {pr_reduced}")
|
||||
failures.append(label)
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
if failures:
|
||||
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
if intentional_diffs:
|
||||
print(
|
||||
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
|
||||
f"no_provider_error → plugin dispatch when a plugin is registered."
|
||||
)
|
||||
if failures or errors:
|
||||
return 1
|
||||
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Behavior-parity check for the TTS plugin hook (issue #30398).
|
||||
|
||||
Spawns one subprocess per (version, scenario) cell — pinned to either
|
||||
``origin/main`` (no plugin hook; ``tts.provider: cartesia`` falls
|
||||
through to the Edge TTS default branch) or this PR's worktree (plugin
|
||||
hook present; same config routes through the plugin registry when a
|
||||
plugin is registered).
|
||||
|
||||
Each subprocess clears all TTS-related env vars + writes a
|
||||
``config.yaml``, then resolves how the dispatcher would route a
|
||||
``text_to_speech`` call. The emitted shape tuple is::
|
||||
|
||||
{dispatch_kind, provider_name, voice_compat}
|
||||
|
||||
Where ``dispatch_kind`` ∈
|
||||
``{"builtin_edge", "builtin_openai", "builtin_elevenlabs", ...,
|
||||
"command", "plugin", "fallback_edge", "error"}``:
|
||||
|
||||
* ``builtin_<name>`` — config selects a built-in handler that exists
|
||||
on both main and PR (no diff expected)
|
||||
* ``command`` — config selects a ``tts.providers.<name>: type: command``
|
||||
entry (PR #17843; no diff expected)
|
||||
* ``plugin`` — config selects a plugin-registered provider (PR only)
|
||||
* ``fallback_edge`` — config selects an unknown name with no matching
|
||||
plugin or command entry → Edge TTS default fallback
|
||||
* ``error`` — explicit fatal error (e.g. mistral quarantine)
|
||||
|
||||
The parent process diffs the reduced shape per scenario. The only
|
||||
acceptable diff is ``fallback_edge → plugin`` for the
|
||||
``unknown-name-with-plugin-installed`` scenario — everything else is
|
||||
a regression.
|
||||
|
||||
Run from the PR worktree (it auto-resolves ``MAIN_DIR`` from the parent
|
||||
of the worktree directory, or falls back to a sibling
|
||||
``hermes-agent-main`` checkout)::
|
||||
|
||||
python tests/plugins/tts/check_parity_vs_main.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _resolve_main_dir() -> Path:
|
||||
candidate = REPO_ROOT.parent.parent
|
||||
if (candidate / "tools" / "tts_tool.py").exists() and candidate != REPO_ROOT:
|
||||
return candidate
|
||||
sibling = REPO_ROOT.parent / "hermes-agent-main"
|
||||
if (sibling / "tools" / "tts_tool.py").exists():
|
||||
return sibling
|
||||
return REPO_ROOT
|
||||
|
||||
|
||||
MAIN_DIR = _resolve_main_dir()
|
||||
PR_DIR = REPO_ROOT
|
||||
assert (PR_DIR / "tools" / "tts_tool.py").exists(), (
|
||||
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
|
||||
)
|
||||
|
||||
|
||||
# The subprocess script — runs INSIDE either the main checkout or PR
|
||||
# checkout, so the import paths resolve to the version of the code
|
||||
# under test. We never call the real ``text_to_speech_tool`` because
|
||||
# that would require audio synthesis; instead we ask the resolution
|
||||
# layer what it WOULD do.
|
||||
SUBPROCESS_SCRIPT = r"""
|
||||
import json, os, sys, tempfile
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
# Isolated HERMES_HOME so the config write is hermetic.
|
||||
home = tempfile.mkdtemp()
|
||||
os.environ["HERMES_HOME"] = home
|
||||
|
||||
# Clear TTS-related env so dispatch decisions are config-driven.
|
||||
for k in (
|
||||
"ELEVENLABS_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY",
|
||||
"MINIMAX_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY",
|
||||
):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
scenario_env = json.loads(sys.argv[2])
|
||||
os.environ.update(scenario_env)
|
||||
|
||||
config_yaml = sys.argv[3]
|
||||
plugin_register = sys.argv[4] # "yes" to register a fake plugin
|
||||
|
||||
config_path = os.path.join(home, "config.yaml")
|
||||
with open(config_path, "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
# Fresh import — must not have anything cached from prior runs.
|
||||
for name in list(sys.modules):
|
||||
if (name.startswith("tools.")
|
||||
or name.startswith("agent.")
|
||||
or name.startswith("plugins.")
|
||||
or name.startswith("hermes_cli.")):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
# Try importing tts_registry — only exists on PR side.
|
||||
have_plugin_hook = False
|
||||
try:
|
||||
from agent import tts_registry
|
||||
from agent.tts_provider import TTSProvider
|
||||
have_plugin_hook = True
|
||||
|
||||
if plugin_register == "yes":
|
||||
class _FakeProvider(TTSProvider):
|
||||
@property
|
||||
def name(self): return "cartesia"
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
return output_path
|
||||
|
||||
tts_registry._reset_for_tests()
|
||||
tts_registry.register_provider(_FakeProvider())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import tools.tts_tool as tts_tool
|
||||
|
||||
# Read the config the same way text_to_speech_tool() does.
|
||||
tts_config = tts_tool._load_tts_config()
|
||||
provider = tts_tool._get_provider(tts_config)
|
||||
|
||||
dispatch_kind = None
|
||||
provider_name = provider
|
||||
voice_compat = False
|
||||
error_text = None
|
||||
|
||||
try:
|
||||
# Mistral is the one branch that returns a fatal error.
|
||||
if provider == "mistral":
|
||||
dispatch_kind = "error"
|
||||
error_text = "mistral quarantine"
|
||||
elif tts_tool._resolve_command_provider_config(provider, tts_config) is not None:
|
||||
dispatch_kind = "command"
|
||||
elif have_plugin_hook and provider not in tts_tool.BUILTIN_TTS_PROVIDERS:
|
||||
# On PR side: check plugin dispatch.
|
||||
plugin_path = tts_tool._dispatch_to_plugin_provider(
|
||||
"test", os.path.join(home, "out.mp3"), provider, tts_config,
|
||||
)
|
||||
if plugin_path is not None:
|
||||
dispatch_kind = "plugin"
|
||||
voice_compat = tts_tool._plugin_provider_is_voice_compatible(provider)
|
||||
else:
|
||||
# Falls through to Edge TTS default on the PR side too.
|
||||
dispatch_kind = "fallback_edge"
|
||||
elif provider in tts_tool.BUILTIN_TTS_PROVIDERS:
|
||||
dispatch_kind = "builtin_" + provider
|
||||
else:
|
||||
# On main side: unknown names fall through to Edge default.
|
||||
dispatch_kind = "fallback_edge"
|
||||
except Exception as exc:
|
||||
dispatch_kind = "exception"
|
||||
error_text = repr(exc)
|
||||
|
||||
shape = {
|
||||
"dispatch_kind": dispatch_kind,
|
||||
"provider_name": provider_name,
|
||||
"voice_compat": bool(voice_compat),
|
||||
"error_present": error_text is not None,
|
||||
}
|
||||
print(json.dumps(shape))
|
||||
"""
|
||||
|
||||
|
||||
SCENARIOS: list[tuple[str, str, dict[str, str], str]] = [
|
||||
# (label, config.yaml body, scenario_env, plugin_register)
|
||||
|
||||
# Scenario 1: unset tts.provider → both: Edge default
|
||||
("unset-defaults-to-edge", "", {}, "no"),
|
||||
|
||||
# Scenario 2: built-in name → both: that built-in
|
||||
("explicit-edge", "tts:\n provider: edge\n", {}, "no"),
|
||||
("explicit-openai", "tts:\n provider: openai\n", {}, "no"),
|
||||
("explicit-elevenlabs", "tts:\n provider: elevenlabs\n", {}, "no"),
|
||||
|
||||
# Scenario 3: command-type provider → both: command dispatch
|
||||
(
|
||||
"command-provider",
|
||||
"tts:\n provider: my-piper\n providers:\n my-piper:\n type: command\n command: 'piper -m model.onnx -f {output_path} < {input_path}'\n",
|
||||
{},
|
||||
"no",
|
||||
),
|
||||
|
||||
# Scenario 4: unknown name with NO plugin installed → both: fallback to Edge
|
||||
("unknown-no-plugin", "tts:\n provider: cartesia\n", {}, "no"),
|
||||
|
||||
# Scenario 5: unknown name WITH plugin installed
|
||||
# main: fallback_edge (no plugin hook exists)
|
||||
# PR: plugin (cartesia)
|
||||
# This is the ONLY acceptable diff in the harness.
|
||||
("plugin-installed", "tts:\n provider: cartesia\n", {}, "yes"),
|
||||
|
||||
# Scenario 6: built-in name + plugin tries to shadow → both: built-in
|
||||
# The plugin registers under name "cartesia", not "edge", so this is
|
||||
# effectively the same as scenario 2 — but we exercise the with-plugin
|
||||
# path to ensure the built-in branch still takes priority.
|
||||
("explicit-edge-with-plugin-registered", "tts:\n provider: edge\n", {}, "yes"),
|
||||
|
||||
# Scenario 7: mistral quarantine — both surface the explicit error
|
||||
("mistral-quarantine", "tts:\n provider: mistral\n", {}, "no"),
|
||||
]
|
||||
|
||||
|
||||
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict, plugin_register: str) -> dict:
|
||||
venv_python = repo_path / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / "venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = Path("python3")
|
||||
|
||||
out = subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
SUBPROCESS_SCRIPT,
|
||||
str(repo_path),
|
||||
json.dumps(env),
|
||||
config_yaml,
|
||||
plugin_register,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return {
|
||||
"error": "subprocess failed",
|
||||
"stdout": out.stdout[-500:],
|
||||
"stderr": out.stderr[-500:],
|
||||
}
|
||||
try:
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
except Exception as exc:
|
||||
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
|
||||
|
||||
|
||||
def _reduce(shape: dict) -> dict:
|
||||
"""Reduce to the parts that matter for user-visible parity."""
|
||||
return {
|
||||
"dispatch_kind": shape.get("dispatch_kind"),
|
||||
"provider_name": shape.get("provider_name"),
|
||||
"error_present": shape.get("error_present"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"main: {MAIN_DIR}")
|
||||
print(f"pr: {PR_DIR}")
|
||||
print()
|
||||
|
||||
if MAIN_DIR == PR_DIR:
|
||||
print(
|
||||
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
|
||||
" Set up a sibling 'hermes-agent-main' checkout pinned to "
|
||||
"origin/main to get real parity coverage."
|
||||
)
|
||||
print()
|
||||
|
||||
failures: list[str] = []
|
||||
errors: list[str] = []
|
||||
intentional_diffs: list[tuple[str, dict, dict]] = []
|
||||
for label, config_yaml, env, plugin_register in SCENARIOS:
|
||||
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env, plugin_register)
|
||||
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env, plugin_register)
|
||||
|
||||
if "error" in main_shape or "error" in pr_shape:
|
||||
print(f" [ERR ] {label}: subprocess failed")
|
||||
print(f" main: {main_shape}")
|
||||
print(f" pr: {pr_shape}")
|
||||
errors.append(label)
|
||||
continue
|
||||
|
||||
main_reduced = _reduce(main_shape)
|
||||
pr_reduced = _reduce(pr_shape)
|
||||
|
||||
if main_reduced == pr_reduced:
|
||||
print(f" [OK] {label}: {main_reduced}")
|
||||
continue
|
||||
|
||||
# On main, "plugin-installed" scenario returns fallback_edge
|
||||
# (no plugin hook); on PR, it routes to the plugin. That's the
|
||||
# only acceptable diff.
|
||||
fallback_to_plugin = (
|
||||
main_reduced.get("dispatch_kind") == "fallback_edge"
|
||||
and pr_reduced.get("dispatch_kind") == "plugin"
|
||||
and label == "plugin-installed"
|
||||
)
|
||||
if fallback_to_plugin:
|
||||
print(f" [DIFF] {label}: fallback_edge → plugin — expected")
|
||||
intentional_diffs.append((label, main_reduced, pr_reduced))
|
||||
else:
|
||||
print(f" [FAIL] {label}")
|
||||
print(f" main: {main_reduced}")
|
||||
print(f" pr: {pr_reduced}")
|
||||
failures.append(label)
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
if failures:
|
||||
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
if intentional_diffs:
|
||||
print(
|
||||
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
|
||||
f"fallback_edge → plugin dispatch when a plugin is registered."
|
||||
)
|
||||
if failures or errors:
|
||||
return 1
|
||||
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Make tests/plugins/video_gen a package."""
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Tests for the FAL video gen plugin — family routing, payload shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import video_gen_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
video_gen_registry._reset_for_tests()
|
||||
yield
|
||||
video_gen_registry._reset_for_tests()
|
||||
|
||||
|
||||
def test_fal_provider_registers():
|
||||
from plugins.video_gen.fal import FALVideoGenProvider, DEFAULT_MODEL
|
||||
|
||||
provider = FALVideoGenProvider()
|
||||
video_gen_registry.register_provider(provider)
|
||||
|
||||
assert video_gen_registry.get_provider("fal") is provider
|
||||
assert provider.display_name == "FAL"
|
||||
# DEFAULT_MODEL is the cheap-tier default
|
||||
assert provider.default_model() == DEFAULT_MODEL
|
||||
assert DEFAULT_MODEL in {"pixverse-v6", "ltx-2.3"}
|
||||
|
||||
|
||||
def test_fal_family_catalog():
|
||||
"""Each family declares both endpoints. The catalog covers the
|
||||
cheap + premium tiers Teknium listed."""
|
||||
from plugins.video_gen.fal import FAL_FAMILIES
|
||||
|
||||
expected = {
|
||||
# cheap
|
||||
"ltx-2.3", "pixverse-v6",
|
||||
# premium
|
||||
"veo3.1", "seedance-2.0", "kling-v3-4k", "happy-horse",
|
||||
}
|
||||
assert expected.issubset(set(FAL_FAMILIES.keys())), (
|
||||
f"missing families: {expected - set(FAL_FAMILIES.keys())}"
|
||||
)
|
||||
for fid, meta in FAL_FAMILIES.items():
|
||||
assert meta.get("text_endpoint"), f"{fid} missing text_endpoint"
|
||||
assert meta.get("image_endpoint"), f"{fid} missing image_endpoint"
|
||||
assert meta["text_endpoint"] != meta["image_endpoint"]
|
||||
assert meta.get("tier") in {"cheap", "premium"}, (
|
||||
f"{fid} has invalid tier"
|
||||
)
|
||||
|
||||
|
||||
def test_kling_4k_uses_start_image_url():
|
||||
"""Kling v3 4K's image-to-video endpoint expects start_image_url,
|
||||
not image_url. The family must declare image_param_key='start_image_url'."""
|
||||
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
|
||||
|
||||
meta = FAL_FAMILIES["kling-v3-4k"]
|
||||
assert meta.get("image_param_key") == "start_image_url"
|
||||
payload = _build_payload(
|
||||
meta,
|
||||
prompt="x",
|
||||
image_url="https://example.com/i.png",
|
||||
duration=5,
|
||||
aspect_ratio="16:9",
|
||||
resolution="720p",
|
||||
negative_prompt=None,
|
||||
audio=None,
|
||||
seed=None,
|
||||
)
|
||||
assert payload.get("start_image_url") == "https://example.com/i.png"
|
||||
assert "image_url" not in payload
|
||||
|
||||
|
||||
def test_fal_list_models_advertises_both_modalities():
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
|
||||
models = FALVideoGenProvider().list_models()
|
||||
for m in models:
|
||||
assert set(m["modalities"]) == {"text", "image"}, (
|
||||
f"{m['id']} doesn't advertise both modalities — every family "
|
||||
f"should have t2v + i2v"
|
||||
)
|
||||
|
||||
|
||||
def test_fal_unavailable_without_key(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
# Also ensure managed gateway is unavailable
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
assert FALVideoGenProvider().is_available() is False
|
||||
|
||||
|
||||
def test_fal_generate_requires_fal_key(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
# Also ensure managed gateway is unavailable
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
result = FALVideoGenProvider().generate("a happy dog")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
|
||||
def test_fal_available_via_gateway(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
fal_plugin,
|
||||
"_resolve_managed_fal_video_gateway",
|
||||
lambda: object(), # truthy sentinel — gateway is available
|
||||
)
|
||||
assert FALVideoGenProvider().is_available() is True
|
||||
|
||||
|
||||
class TestFamilyRouting:
|
||||
"""The headline behavior: image_url presence picks the endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def with_fake_fal(self, monkeypatch):
|
||||
"""Stub fal_client.submit to capture which endpoint we hit."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
captured = {"endpoint": None, "arguments": None}
|
||||
|
||||
class FakeHandle:
|
||||
def get(self):
|
||||
return {"video": {"url": "https://fake/out.mp4"}}
|
||||
|
||||
fake = types.ModuleType("fal_client")
|
||||
def _submit(endpoint, arguments=None, headers=None):
|
||||
captured["endpoint"] = endpoint
|
||||
captured["arguments"] = arguments
|
||||
return FakeHandle()
|
||||
fake.submit = _submit # type: ignore
|
||||
monkeypatch.setitem(sys.modules, "fal_client", fake)
|
||||
|
||||
# Reset the lazy global so it picks up our stub
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
fal_plugin._fal_client = None
|
||||
# Also reset the managed client cache
|
||||
fal_plugin._managed_fal_video_client = None
|
||||
fal_plugin._managed_fal_video_client_config = None
|
||||
|
||||
monkeypatch.setenv("FAL_KEY", "test")
|
||||
# Force direct mode — no managed gateway
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
return captured
|
||||
|
||||
def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
|
||||
result = FALVideoGenProvider().generate(
|
||||
"a dog running",
|
||||
model="pixverse-v6",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/text-to-video"
|
||||
assert result["modality"] == "text"
|
||||
assert with_fake_fal["arguments"]["prompt"] == "a dog running"
|
||||
assert "image_url" not in with_fake_fal["arguments"]
|
||||
|
||||
def test_image_to_video_routes_to_image_endpoint(self, with_fake_fal):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
|
||||
result = FALVideoGenProvider().generate(
|
||||
"animate this dog",
|
||||
model="pixverse-v6",
|
||||
image_url="https://example.com/dog.png",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert with_fake_fal["endpoint"] == "fal-ai/pixverse/v6/image-to-video"
|
||||
assert result["modality"] == "image"
|
||||
assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png"
|
||||
|
||||
def test_default_family_text_routing(self, with_fake_fal):
|
||||
"""No model arg → DEFAULT_MODEL → text-to-video endpoint."""
|
||||
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
|
||||
|
||||
result = FALVideoGenProvider().generate("a dog")
|
||||
assert result["success"] is True
|
||||
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"]
|
||||
assert with_fake_fal["endpoint"] == expected_endpoint
|
||||
|
||||
def test_default_family_image_routing(self, with_fake_fal):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
|
||||
|
||||
result = FALVideoGenProvider().generate(
|
||||
"animate this",
|
||||
image_url="https://example.com/i.png",
|
||||
)
|
||||
assert result["success"] is True
|
||||
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["image_endpoint"]
|
||||
assert with_fake_fal["endpoint"] == expected_endpoint
|
||||
|
||||
def test_unknown_family_falls_back_to_default(self, with_fake_fal):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider, FAL_FAMILIES, DEFAULT_MODEL
|
||||
|
||||
result = FALVideoGenProvider().generate(
|
||||
"x",
|
||||
model="not-a-real-family",
|
||||
)
|
||||
assert result["success"] is True
|
||||
expected_endpoint = FAL_FAMILIES[DEFAULT_MODEL]["text_endpoint"]
|
||||
assert with_fake_fal["endpoint"] == expected_endpoint
|
||||
|
||||
def test_premium_seedance_routing(self, with_fake_fal):
|
||||
"""Sanity check the premium-tier seedance routes correctly."""
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
|
||||
result = FALVideoGenProvider().generate(
|
||||
"a dog",
|
||||
model="seedance-2.0",
|
||||
image_url="https://example.com/dog.png",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert with_fake_fal["endpoint"] == "bytedance/seedance-2.0/image-to-video"
|
||||
# Seedance uses regular image_url (not start_image_url)
|
||||
assert with_fake_fal["arguments"]["image_url"] == "https://example.com/dog.png"
|
||||
|
||||
def test_kling_4k_remaps_image_param(self, with_fake_fal):
|
||||
"""Kling v3 4K image-to-video receives start_image_url, not image_url."""
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
|
||||
result = FALVideoGenProvider().generate(
|
||||
"x",
|
||||
model="kling-v3-4k",
|
||||
image_url="https://example.com/frame.png",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert with_fake_fal["endpoint"] == "fal-ai/kling-video/v3/4k/image-to-video"
|
||||
assert with_fake_fal["arguments"].get("start_image_url") == "https://example.com/frame.png"
|
||||
assert "image_url" not in with_fake_fal["arguments"]
|
||||
|
||||
|
||||
class TestPayloadBuilder:
|
||||
def test_drops_unsupported_keys(self):
|
||||
"""Veo enum-clamps duration, supports aspect+resolution+audio+neg."""
|
||||
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
|
||||
|
||||
meta = FAL_FAMILIES["veo3.1"]
|
||||
p = _build_payload(
|
||||
meta,
|
||||
prompt="x",
|
||||
image_url=None,
|
||||
duration=12, # not in enum (4,6,8) — snap to 8
|
||||
aspect_ratio="16:9",
|
||||
resolution="720p",
|
||||
negative_prompt="ugly",
|
||||
audio=True,
|
||||
seed=42,
|
||||
)
|
||||
assert p["prompt"] == "x"
|
||||
assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API
|
||||
assert p["aspect_ratio"] == "16:9"
|
||||
assert p["resolution"] == "720p"
|
||||
assert p["generate_audio"] is True
|
||||
assert p["negative_prompt"] == "ugly"
|
||||
assert p["seed"] == 42
|
||||
|
||||
def test_pixverse_range_clamps_correctly(self):
|
||||
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
|
||||
|
||||
meta = FAL_FAMILIES["pixverse-v6"]
|
||||
p = _build_payload(
|
||||
meta,
|
||||
prompt="x",
|
||||
image_url="https://i.png",
|
||||
duration=99, # over max → 15
|
||||
aspect_ratio="16:9",
|
||||
resolution="540p",
|
||||
negative_prompt=None,
|
||||
audio=None,
|
||||
seed=None,
|
||||
)
|
||||
assert p["duration"] == "15"
|
||||
|
||||
def test_kling_4k_clamps_below_min(self):
|
||||
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
|
||||
|
||||
meta = FAL_FAMILIES["kling-v3-4k"]
|
||||
p = _build_payload(
|
||||
meta,
|
||||
prompt="x",
|
||||
image_url="https://i.png",
|
||||
duration=1, # below min (3) → 3
|
||||
aspect_ratio="16:9",
|
||||
resolution="720p",
|
||||
negative_prompt=None,
|
||||
audio=None,
|
||||
seed=None,
|
||||
)
|
||||
assert p["duration"] == "3"
|
||||
|
||||
def test_ltx_omits_duration_aspect_resolution(self):
|
||||
"""LTX 2.3 doesn't declare duration/aspect/resolution enums —
|
||||
the payload should NOT include those keys (let FAL default)."""
|
||||
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
|
||||
|
||||
meta = FAL_FAMILIES["ltx-2.3"]
|
||||
p = _build_payload(
|
||||
meta,
|
||||
prompt="x",
|
||||
image_url=None,
|
||||
duration=8,
|
||||
aspect_ratio="16:9",
|
||||
resolution="720p",
|
||||
negative_prompt="ugly",
|
||||
audio=True,
|
||||
seed=None,
|
||||
)
|
||||
assert "duration" not in p
|
||||
assert "aspect_ratio" not in p
|
||||
assert "resolution" not in p
|
||||
# But audio + negative are advertised
|
||||
assert p["generate_audio"] is True
|
||||
assert p["negative_prompt"] == "ugly"
|
||||
|
||||
def test_happy_horse_minimal_payload(self):
|
||||
"""Happy Horse has sparse docs — payload should be minimal."""
|
||||
from plugins.video_gen.fal import FAL_FAMILIES, _build_payload
|
||||
|
||||
meta = FAL_FAMILIES["happy-horse"]
|
||||
p = _build_payload(
|
||||
meta,
|
||||
prompt="a horse galloping",
|
||||
image_url=None,
|
||||
duration=8,
|
||||
aspect_ratio="16:9",
|
||||
resolution="720p",
|
||||
negative_prompt="watermark",
|
||||
audio=True,
|
||||
seed=None,
|
||||
)
|
||||
# Only prompt — no payload bloat for fields we can't verify
|
||||
assert p == {"prompt": "a horse galloping"}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Smoke tests for the xAI video gen plugin — load & register surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import video_gen_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
video_gen_registry._reset_for_tests()
|
||||
yield
|
||||
video_gen_registry._reset_for_tests()
|
||||
|
||||
|
||||
def test_xai_provider_registers():
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
provider = XAIVideoGenProvider()
|
||||
video_gen_registry.register_provider(provider)
|
||||
|
||||
assert video_gen_registry.get_provider("xai") is provider
|
||||
assert provider.display_name == "xAI"
|
||||
assert provider.default_model() == "grok-imagine-video"
|
||||
|
||||
|
||||
def test_xai_provider_lists_text_and_current_image_video_models():
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
models = XAIVideoGenProvider().list_models()
|
||||
ids = [model["id"] for model in models]
|
||||
|
||||
assert ids[0] == "grok-imagine-video"
|
||||
assert ids[1] == "grok-imagine-video-1.5"
|
||||
assert models[1]["modalities"] == ["image"]
|
||||
assert "aliases" not in models[1]
|
||||
|
||||
|
||||
def test_xai_routes_default_models_by_modality():
|
||||
from plugins.video_gen.xai import _resolve_model_for_modality
|
||||
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video",
|
||||
modality="text",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video",
|
||||
modality="image",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video-1.5"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video-1.5-preview",
|
||||
modality="text",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video-1.5-preview",
|
||||
modality="text",
|
||||
explicit_model=True,
|
||||
) == "grok-imagine-video-1.5-preview"
|
||||
|
||||
|
||||
def test_xai_capabilities_keep_generate_surface_only():
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
caps = XAIVideoGenProvider().capabilities()
|
||||
assert caps["modalities"] == ["text", "image"]
|
||||
assert "operations" not in caps
|
||||
assert caps["max_reference_images"] == 7
|
||||
|
||||
|
||||
def test_xai_unavailable_without_key(monkeypatch):
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
assert XAIVideoGenProvider().is_available() is False
|
||||
|
||||
|
||||
def test_xai_generate_requires_xai_key(monkeypatch):
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
result = XAIVideoGenProvider().generate("a happy dog")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
|
||||
def test_xai_available_with_oauth_only(monkeypatch):
|
||||
"""The plugin must honour xAI Grok OAuth credentials, not just
|
||||
XAI_API_KEY. Otherwise the agent's tool-availability check filters
|
||||
``video_generate`` out of the toolbelt and the agent silently falls
|
||||
back to whatever skill advertises video generation (e.g. comfyui).
|
||||
"""
|
||||
import plugins.video_gen.xai as xai_plugin
|
||||
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"tools.xai_http.resolve_xai_http_credentials",
|
||||
lambda: {
|
||||
"provider": "xai-oauth",
|
||||
"api_key": "oauth-bearer-token",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
},
|
||||
)
|
||||
|
||||
assert xai_plugin.XAIVideoGenProvider().is_available() is True
|
||||
|
||||
|
||||
def test_xai_resolved_credentials_threaded_through_request(monkeypatch):
|
||||
"""OAuth-resolved creds must reach the HTTP layer — bug class where
|
||||
``is_available()`` says yes but the request still hits with no key.
|
||||
"""
|
||||
import plugins.video_gen.xai as xai_plugin
|
||||
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"tools.xai_http.resolve_xai_http_credentials",
|
||||
lambda: {
|
||||
"provider": "xai-oauth",
|
||||
"api_key": "oauth-bearer-token",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
},
|
||||
)
|
||||
|
||||
api_key, base_url = xai_plugin._resolve_xai_credentials()
|
||||
assert api_key == "oauth-bearer-token"
|
||||
assert base_url == "https://api.x.ai/v1"
|
||||
headers = xai_plugin._xai_headers(api_key)
|
||||
assert headers["Authorization"] == "Bearer oauth-bearer-token"
|
||||
|
||||
|
||||
def test_xai_no_operation_kwarg():
|
||||
"""The ABC's generate() signature no longer accepts 'operation'.
|
||||
Passing it through **kwargs should be ignored (forward-compat)."""
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
# We're not actually hitting the network — just verify the call
|
||||
# doesn't TypeError on the unexpected kwarg.
|
||||
# Will fail with auth_required (no XAI_API_KEY), but should NOT
|
||||
# fail with TypeError.
|
||||
result = XAIVideoGenProvider().generate("x", operation="generate")
|
||||
assert result["success"] is False
|
||||
# auth_required, NOT some signature error
|
||||
assert result["error_type"] in {"auth_required", "api_error"}
|
||||
|
||||
|
||||
def test_xai_video_output_urls_prefers_stored_public_url():
|
||||
from plugins.video_gen.xai import _xai_video_output_urls
|
||||
|
||||
public_url, temporary, stored = _xai_video_output_urls({
|
||||
"url": "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4",
|
||||
"file_output": {
|
||||
"public_url": "https://files-cdn.x.ai/token/file_abc.mp4",
|
||||
"file_id": "file_abc",
|
||||
},
|
||||
})
|
||||
assert public_url == "https://files-cdn.x.ai/token/file_abc.mp4"
|
||||
assert stored == "https://files-cdn.x.ai/token/file_abc.mp4"
|
||||
assert temporary == "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_input_from_public_url_uses_url_field():
|
||||
from plugins.video_gen.xai import _video_input_from_public_url
|
||||
|
||||
url = "https://files-cdn.x.ai/kRQVP6PRQlioVAUNC3GAdg/file_1faca9c3-9411-46ad-bb41-b9b8527789e6.mp4"
|
||||
result = await _video_input_from_public_url(
|
||||
url,
|
||||
api_key="test-key",
|
||||
base_url="https://api.x.ai/v1",
|
||||
)
|
||||
assert result == {"url": url}
|
||||
|
||||
|
||||
def test_video_input_from_public_url_rejects_bare_file_id():
|
||||
import asyncio
|
||||
from plugins.video_gen.xai import _video_input_from_public_url
|
||||
|
||||
result = asyncio.run(
|
||||
_video_input_from_public_url(
|
||||
"file_1faca9c3-9411-46ad-bb41-b9b8527789e6",
|
||||
api_key="test-key",
|
||||
base_url="https://api.x.ai/v1",
|
||||
)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_xai_video_image_input_blocks_credential_store_symlink(tmp_path, monkeypatch):
|
||||
from plugins.video_gen.xai import _image_ref_to_xai_input
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
image_link = hermes_home / "leak.png"
|
||||
try:
|
||||
image_link.symlink_to(auth_json)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink unavailable on this platform: {exc}")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_image_ref_to_xai_input(str(image_link))
|
||||
|
||||
|
||||
def test_xai_video_file_input_blocks_credential_store_symlink(tmp_path, monkeypatch):
|
||||
from plugins.video_gen.xai import _video_ref_to_xai_url
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
video_link = hermes_home / "leak.mp4"
|
||||
try:
|
||||
video_link.symlink_to(auth_json)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink unavailable on this platform: {exc}")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_video_ref_to_xai_url(str(video_link))
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Integration tests for the xAI video gen plugin's simplified surface.
|
||||
|
||||
xAI exposes only text-to-video and image-to-video through the unified
|
||||
``video_generate`` tool. We assert the endpoint hit and the payload shape
|
||||
because routing is the part most likely to break silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import video_gen_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
video_gen_registry._reset_for_tests()
|
||||
yield
|
||||
video_gen_registry._reset_for_tests()
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status: int = 200, payload: Optional[Dict[str, Any]] = None):
|
||||
self.status_code = status
|
||||
self._payload = payload or {}
|
||||
self.text = json.dumps(self._payload)
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
import httpx
|
||||
raise httpx.HTTPStatusError("err", request=None, response=self) # type: ignore
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self):
|
||||
self.posts: List[Dict[str, Any]] = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return None
|
||||
|
||||
async def post(self, url, headers=None, json=None, timeout=None):
|
||||
self.posts.append({"url": url, "json": json})
|
||||
return _FakeResponse(200, {"request_id": "req-123"})
|
||||
|
||||
async def get(self, url, headers=None, timeout=None):
|
||||
return _FakeResponse(200, {
|
||||
"status": "done",
|
||||
"video": {"url": "https://xai-cdn/out.mp4", "duration": 8},
|
||||
"model": self.posts[-1]["json"]["model"],
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xai_provider(monkeypatch):
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-key")
|
||||
|
||||
import plugins.video_gen.xai as xai_plugin
|
||||
|
||||
captured: Dict[str, _FakeAsyncClient] = {}
|
||||
|
||||
def _client_factory():
|
||||
captured["client"] = _FakeAsyncClient()
|
||||
return captured["client"]
|
||||
|
||||
monkeypatch.setattr(xai_plugin.httpx, "AsyncClient", _client_factory)
|
||||
|
||||
async def _no_sleep(*a, **k):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
|
||||
|
||||
provider = xai_plugin.XAIVideoGenProvider()
|
||||
return provider, captured
|
||||
|
||||
|
||||
def _last_post(captured) -> Dict[str, Any]:
|
||||
return captured["client"].posts[-1]
|
||||
|
||||
|
||||
class TestXAIEndpoint:
|
||||
"""xAI uses one endpoint — ``/videos/generations`` — for both modes."""
|
||||
|
||||
def test_text_to_video_hits_generations(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
result = provider.generate("a dog on a skateboard")
|
||||
assert result["success"] is True
|
||||
assert _last_post(captured)["url"].endswith("/videos/generations")
|
||||
assert result["modality"] == "text"
|
||||
|
||||
def test_image_to_video_hits_generations(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
result = provider.generate(
|
||||
"animate this",
|
||||
image_url="https://example.com/cat.png",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert _last_post(captured)["url"].endswith("/videos/generations")
|
||||
assert result["modality"] == "image"
|
||||
|
||||
|
||||
class TestXAIPayload:
|
||||
def test_text_payload_has_no_image_field(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate("a dog at sunset")
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
assert payload["prompt"] == "a dog at sunset"
|
||||
assert "image" not in payload
|
||||
assert "reference_images" not in payload
|
||||
|
||||
def test_image_payload_has_image_field(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate("animate this", image_url="https://example.com/cat.png")
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["image"] == {"url": "https://example.com/cat.png"}
|
||||
|
||||
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
|
||||
provider, captured = xai_provider
|
||||
image_path = tmp_path / "frame.png"
|
||||
image_path.write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
|
||||
provider.generate("animate this", image_url=str(image_path))
|
||||
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["image"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate(
|
||||
"animate this",
|
||||
image_url="https://example.com/cat.png",
|
||||
model="grok-imagine-video",
|
||||
_model_override_explicit=True,
|
||||
)
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
|
||||
def test_reference_images_payload(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate(
|
||||
"keep this character",
|
||||
reference_image_urls=[
|
||||
"https://example.com/a.png",
|
||||
"https://example.com/b.png",
|
||||
],
|
||||
)
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["reference_images"] == [
|
||||
{"url": "https://example.com/a.png"},
|
||||
{"url": "https://example.com/b.png"},
|
||||
]
|
||||
|
||||
|
||||
class TestXAIValidation:
|
||||
def test_missing_prompt_rejects(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
result = provider.generate("")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "missing_prompt"
|
||||
# Never hit the network
|
||||
assert "client" not in captured or not captured["client"].posts
|
||||
|
||||
def test_image_plus_refs_rejects(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
result = provider.generate(
|
||||
"x",
|
||||
image_url="https://example.com/i.png",
|
||||
reference_image_urls=["https://example.com/r.png"],
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "conflicting_inputs"
|
||||
assert "client" not in captured or not captured["client"].posts
|
||||
|
||||
def test_too_many_references_rejects(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
result = provider.generate(
|
||||
"x",
|
||||
reference_image_urls=[f"https://example.com/r{i}.png" for i in range(8)],
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "too_many_references"
|
||||
|
||||
|
||||
class TestXAIClamping:
|
||||
def test_duration_clamped_to_15(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate("x", duration=30)
|
||||
assert _last_post(captured)["json"]["duration"] == 15
|
||||
|
||||
def test_duration_clamped_when_refs_present(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate(
|
||||
"x",
|
||||
duration=15,
|
||||
reference_image_urls=["https://example.com/r.png"],
|
||||
)
|
||||
# refs present caps to 10
|
||||
assert _last_post(captured)["json"]["duration"] == 10
|
||||
|
||||
def test_invalid_aspect_ratio_soft_clamps(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate("x", aspect_ratio="21:9")
|
||||
assert _last_post(captured)["json"]["aspect_ratio"] == "16:9"
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Plugin-side tests for the web search provider migration (PR #25182).
|
||||
|
||||
Covers:
|
||||
|
||||
- All eight bundled plugins (brave-free, ddgs, searxng, exa, parallel,
|
||||
tavily, firecrawl, xai) instantiate and self-report the expected
|
||||
capabilities + ABC-derived defaults.
|
||||
- Each plugin's ``is_available()`` correctly reflects env-var presence.
|
||||
- The web_search_registry resolves an active provider in the documented
|
||||
scenarios (explicit config wins ignoring availability, fallback walks
|
||||
legacy preference filtered by availability, unknown name falls back).
|
||||
- Plugin response shapes match the legacy bit-for-bit contract.
|
||||
|
||||
Per the dev skill: these tests use *real* imports from the plugin
|
||||
modules — no mocking of provider classes themselves — so the test
|
||||
catches drift in the ABC interface, the registry, and the plugin
|
||||
glue layer simultaneously.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Strip every web-provider env var so is_available() returns False."""
|
||||
for k in (
|
||||
"BRAVE_SEARCH_API_KEY",
|
||||
"SEARXNG_URL",
|
||||
"TAVILY_API_KEY",
|
||||
"TAVILY_BASE_URL",
|
||||
"EXA_API_KEY",
|
||||
"PARALLEL_API_KEY",
|
||||
"PARALLEL_SEARCH_MODE",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
"XAI_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
def _ensure_plugins_loaded() -> None:
|
||||
"""Idempotently load plugins so the registry is populated."""
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-plugin discovery + capability flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Each test starts with a clean web-provider env."""
|
||||
_clear_web_env(monkeypatch)
|
||||
|
||||
|
||||
class TestBundledPluginsRegister:
|
||||
"""All eight bundled web plugins discover and register correctly."""
|
||||
|
||||
def test_all_seven_plugins_present_in_registry(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import list_providers
|
||||
|
||||
names = sorted(p.name for p in list_providers())
|
||||
assert names == [
|
||||
"brave-free",
|
||||
"ddgs",
|
||||
"exa",
|
||||
"firecrawl",
|
||||
"parallel",
|
||||
"searxng",
|
||||
"tavily",
|
||||
"xai",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name,expected_search,expected_extract",
|
||||
[
|
||||
("brave-free", True, False),
|
||||
("ddgs", True, False),
|
||||
("searxng", True, False),
|
||||
("exa", True, True),
|
||||
("parallel", True, True),
|
||||
("tavily", True, True),
|
||||
("firecrawl", True, True),
|
||||
# xai: search-only via Grok's agentic web_search tool.
|
||||
("xai", True, False),
|
||||
],
|
||||
)
|
||||
def test_capability_flags_match_spec(
|
||||
self,
|
||||
plugin_name: str,
|
||||
expected_search: bool,
|
||||
expected_extract: bool,
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None, f"plugin {plugin_name!r} not registered"
|
||||
assert provider.supports_search() is expected_search
|
||||
assert provider.supports_extract() is expected_extract
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"],
|
||||
)
|
||||
def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None
|
||||
assert provider.name == plugin_name
|
||||
assert provider.display_name # any non-empty string
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"],
|
||||
)
|
||||
def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
|
||||
"""``get_setup_schema()`` returns a dict the picker can consume."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None
|
||||
schema = provider.get_setup_schema()
|
||||
assert isinstance(schema, dict)
|
||||
assert "name" in schema
|
||||
assert "env_vars" in schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_available() behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
"""Each plugin's ``is_available()`` returns False without env config."""
|
||||
|
||||
def test_brave_free_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("brave-free")
|
||||
assert p is not None
|
||||
assert p.is_available() is False # no BRAVE_SEARCH_API_KEY
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_searxng_requires_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("searxng")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_tavily_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("tavily")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_exa_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("exa")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("EXA_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_firecrawl_requires_either_key_or_url(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
|
||||
# Either FIRECRAWL_API_KEY or FIRECRAWL_API_URL lights it up.
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
||||
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_ddgs_always_available_when_package_importable(self) -> None:
|
||||
"""DDGS is the always-on fallback — no API key required.
|
||||
|
||||
It may report unavailable if the ``ddgs`` package itself isn't
|
||||
installed in the env (legitimate — the plugin's post_setup hook
|
||||
triggers pip install on first selection). We only assert that
|
||||
is_available() doesn't raise.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("ddgs")
|
||||
assert p is not None
|
||||
# Truthy or falsy, just must not raise.
|
||||
_ = bool(p.is_available())
|
||||
|
||||
def test_xai_requires_api_key_or_oauth(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""xAI needs XAI_API_KEY or OAuth tokens in auth.json."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("xai")
|
||||
assert p is not None
|
||||
assert p.is_available() is False # no XAI_API_KEY, no auth.json
|
||||
monkeypatch.setenv("XAI_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry resolution semantics (Option B — conservative smart fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryResolution:
|
||||
"""``_resolve()`` follows explicit-config + availability-filtered fallback."""
|
||||
|
||||
def test_explicit_configured_provider_returned_even_when_unavailable(
|
||||
self,
|
||||
) -> None:
|
||||
"""Explicit ``web.search_backend`` wins regardless of is_available().
|
||||
|
||||
Without availability filtering on the explicit path, the dispatcher
|
||||
would silently switch backends; with this check the dispatcher
|
||||
surfaces a precise "FOO_API_KEY is not set" error instead.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
# No BRAVE_SEARCH_API_KEY (fixture cleared it).
|
||||
result = _resolve("brave-free", capability="search")
|
||||
assert result is not None
|
||||
assert result.name == "brave-free"
|
||||
# Confirm it's the unavailable one — dispatcher will surface
|
||||
# a typed credential-missing error to the caller.
|
||||
assert result.is_available() is False
|
||||
|
||||
def test_unknown_configured_name_falls_back_to_available_provider(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Typo / uninstalled plugin → walk legacy preference, pick available."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
monkeypatch.setenv("EXA_API_KEY", "real")
|
||||
result = _resolve("not-a-real-provider", capability="search")
|
||||
# Either ddgs (no-key fallback) or exa (the only available
|
||||
# premium provider) — both are valid. The point is the unknown
|
||||
# name shouldn't return None when SOMETHING is available.
|
||||
assert result is not None
|
||||
assert result.is_available() is True
|
||||
|
||||
def test_explicit_search_only_provider_for_extract_falls_back(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Asking for extract via a search-only backend → fall back.
|
||||
|
||||
``brave-free`` is search-only (``supports_extract() is False``).
|
||||
When the registry resolves it for an extract capability, the
|
||||
explicit-config branch rejects it as capability-incompatible
|
||||
and the fallback walk picks an extract-capable provider.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
monkeypatch.setenv("EXA_API_KEY", "real")
|
||||
result = _resolve("brave-free", capability="extract")
|
||||
# Should land on exa (only extract-capable available provider).
|
||||
assert result is not None
|
||||
assert result.supports_extract() is True
|
||||
assert result.is_available() is True
|
||||
|
||||
def test_no_config_no_credentials_returns_none(
|
||||
self,
|
||||
) -> None:
|
||||
"""No backend configured AND no available providers → typically None.
|
||||
|
||||
``ddgs`` is the no-credential fallback; if its ``ddgs`` Python
|
||||
package is installed in the test env, ddgs will be picked.
|
||||
Otherwise the resolver returns None. Either outcome is correct.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
result = _resolve(None, capability="search")
|
||||
if result is not None:
|
||||
# The only no-credential provider is ddgs; anything else
|
||||
# means an env var leaked in.
|
||||
assert result.is_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync-vs-async extract detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncExtractDispatch:
|
||||
"""The dispatcher detects async vs sync extract methods correctly."""
|
||||
|
||||
def test_parallel_extract_is_async(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is True
|
||||
|
||||
def test_firecrawl_extract_is_async(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is True
|
||||
|
||||
def test_exa_extract_is_sync(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("exa")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is False
|
||||
|
||||
def test_tavily_extract_is_sync(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("tavily")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error response shape (preserved bit-for-bit from legacy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorResponseShapes:
|
||||
"""When credentials are missing, plugins return typed errors, not raises."""
|
||||
|
||||
def test_brave_free_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("brave-free")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_searxng_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("searxng")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_exa_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("exa")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_tavily_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("tavily")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_parallel_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
result = asyncio.run(p.extract(["https://example.com"]))
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert "error" in result[0]
|
||||
assert result[0]["url"] == "https://example.com"
|
||||
|
||||
def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
# firecrawl extract returns [] when the website-policy gate rejects
|
||||
# the URL, or a per-URL error dict when the gate passes but the
|
||||
# firecrawl client fails. Use a URL the policy allows to make sure
|
||||
# we hit the credential-missing path.
|
||||
result = asyncio.run(p.extract(["https://example.com"]))
|
||||
assert isinstance(result, list)
|
||||
if result: # if anything came back, it should be an error entry
|
||||
assert "error" in result[0]
|
||||
|
||||
def test_firecrawl_config_error_points_paid_users_to_nous_subscription(self, monkeypatch):
|
||||
from plugins.web.firecrawl import provider as firecrawl_provider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.web_tools.managed_nous_tools_enabled",
|
||||
lambda: True,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
firecrawl_provider._raise_web_backend_configuration_error()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "With your Nous subscription you can also use the Tool Gateway" in message
|
||||
assert "select Nous Subscription as the web provider" in message
|
||||
assert "managed Firecrawl web tools is unavailable" not in message
|
||||
|
||||
def test_firecrawl_config_error_uses_entitlement_message_when_not_paid(self, monkeypatch):
|
||||
from plugins.web.firecrawl import provider as firecrawl_provider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.web_tools.managed_nous_tools_enabled",
|
||||
lambda: False,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.web_tools.nous_tool_gateway_unavailable_message",
|
||||
lambda capability: f"{capability} denied by test entitlement.",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
firecrawl_provider._raise_web_backend_configuration_error()
|
||||
|
||||
assert "managed Firecrawl web tools denied by test entitlement" in str(exc_info.value)
|
||||
|
||||
def test_xai_search_returns_error_dict_when_unconfigured(self) -> None:
|
||||
"""xAI returns a typed error dict (no XAI_API_KEY)."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("xai")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
Reference in New Issue
Block a user