hermes-agent/tests/hermes_cli/test_nous_subscription.py
Teknium 2319dbb014 fix(desktop): honest browser-backend readiness + explicit backend activation + full OpenAI TTS voice/model options
Three GUI Capabilities-tab defects reported on Windows:

1. Browser rows stuck on 'Setup required' after a successful setup run.
   Root causes, all in the readiness probe (not the installer):
   - _has_agent_browser() never searched the Hermes-managed Node dir
     (%LOCALAPPDATA%/hermes/node / $HERMES_HOME/node/bin) where the
     Windows install lands, and probed node_modules/.bin/agent-browser
     as the extensionless POSIX shim, which fails exec on Windows
     (WinError 193) — now resolved via PATHEXT-aware shutil.which
     against both rungs, mirroring _find_agent_browser().
   - Cloud rows (Nous Subscription Browser Use, Browserbase, Browser
     Use, Firecrawl) declared post_setup: agent_browser, whose
     readiness gate requires a LOCAL Chromium build the cloud never
     uses — switched to the cloud-scoped 'browserbase' hook (CLI-only).
   - _agent_browser_installed() could read browser_tool's stale cached
     'Chromium missing' result from before the install ran in the
     spawned post-setup process — cache now dropped before probing so
     the pill flips to Ready right after a successful run.

2. No way to tell which backend is active, and clicking a row to read
   its details silently rewrote config. Row click now only
   expands/collapses; activation is an explicit 'Use this backend'
   button, the active row carries an 'Active' pill, and the expanded
   active row says 'This is your active backend'.

3. OpenAI TTS showed one model and one voice. The options were always
   defined but rendered through a native <datalist>, which filters by
   the field's current value — a field already set to a valid option
   suggested only itself. Replaced with a real combobox (Input +
   dropdown) that lists every option, and voice suggestions now track
   the selected model per the OpenAI TTS docs: tts-1/tts-1-hd = 9
   voices, gpt-4o-mini-tts = 13 (adds ballad, verse, marin, cedar).
2026-07-28 23:52:49 -07:00

890 lines
35 KiB
Python

"""Tests for Nous subscription feature detection."""
from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo
from hermes_cli import nous_subscription as ns
_POOL_COVERAGE = {
"firecrawl": True,
"fal": True,
"fal-video": False,
"openai-audio": True,
"browser-use": True,
"modal": True,
}
def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountInfo:
return NousPortalAccountInfo(
logged_in=logged_in,
source="jwt" if logged_in else "none",
fresh=False,
paid_service_access=paid,
)
def _pool_account() -> NousPortalAccountInfo:
"""A $0 subscriber with a live free tool pool (no paid access)."""
return NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=False,
tool_access=NousToolAccessInfo(enabled=True, coverage=_POOL_COVERAGE),
)
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
env = {"EXA_API_KEY": "exa-test"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
features = ns.get_nous_subscription_features({"web": {"backend": "exa"}})
assert features.web.available is True
assert features.web.active is True
assert features.web.managed_by_nous is False
assert features.web.direct_override is True
assert features.web.current_provider == "exa"
def test_get_nous_subscription_features_force_fresh_forwards_account_request(monkeypatch):
calls = []
def fake_account_info(*, force_fresh=False):
calls.append(force_fresh)
return _account(logged_in=True, paid=True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_portal_account_info", fake_account_info)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
features = ns.get_nous_subscription_features({}, force_fresh=True)
assert features.account_info is not None
assert features.account_info.paid_service_access is True
assert calls == [True]
def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch):
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "terminal")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: True)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: vendor == "modal")
features = ns.get_nous_subscription_features(
{"terminal": {"backend": "modal", "modal_mode": "auto"}}
)
assert features.modal.available is True
assert features.modal.active is True
assert features.modal.managed_by_nous is True
assert features.modal.direct_override is False
def test_get_nous_subscription_features_marks_browser_use_as_managed_when_gateway_ready(monkeypatch):
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(
ns,
"is_managed_tool_gateway_ready",
lambda vendor: vendor == "browser-use",
)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "browser-use"}}
)
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.managed_by_nous is True
assert features.browser.direct_override is False
assert features.browser.current_provider == "Browser Use"
def test_get_nous_subscription_features_uses_direct_browserbase_when_no_managed_gateway(monkeypatch):
"""When direct Browserbase keys are set and no managed gateway is available,
the unconfigured fallback should pick Browserbase as a direct provider."""
env = {
"BROWSERBASE_API_KEY": "bb-key",
"BROWSERBASE_PROJECT_ID": "bb-project",
}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(
ns,
"is_managed_tool_gateway_ready",
lambda vendor: False, # No managed gateway available
)
features = ns.get_nous_subscription_features({})
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.managed_by_nous is False
assert features.browser.direct_override is True
assert features.browser.current_provider == "Browserbase"
def test_get_nous_subscription_features_prefers_camofox_over_managed_browser_use(monkeypatch):
env = {"CAMOFOX_URL": "http://localhost:9377"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(
ns,
"is_managed_tool_gateway_ready",
lambda vendor: vendor == "browser-use",
)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "browser-use"}}
)
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.managed_by_nous is False
assert features.browser.direct_override is True
assert features.browser.current_provider == "Camofox"
def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(monkeypatch):
env = {
"BROWSERBASE_API_KEY": "bb-key",
"BROWSERBASE_PROJECT_ID": "bb-project",
}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "browserbase"}}
)
assert features.browser.available is False
assert features.browser.active is False
assert features.browser.managed_by_nous is False
assert features.browser.current_provider == "Browserbase"
def test_get_nous_subscription_features_does_not_treat_quoted_false_as_gateway_opt_in(monkeypatch):
env = {"EXA_API_KEY": "exa-test"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: vendor == "firecrawl")
features = ns.get_nous_subscription_features(
{"web": {"backend": "exa", "use_gateway": "false"}}
)
assert features.web.available is True
assert features.web.active is True
assert features.web.managed_by_nous is False
assert features.web.direct_override is True
assert features.web.current_provider == "exa"
def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
# Paid account: entitled to every category, including video.
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {
"web": True,
"image_gen": False,
"video_gen": False,
"tts": False,
"stt": False,
"browser": False,
},
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
{
"model": {"provider": "nous"},
"web": {"use_gateway": "false"},
}
)
assert "web" in has_direct
assert "web" not in already_managed
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "stt", "browser"}
def _stub_browser_probes(monkeypatch, *, has_agent_browser, chromium, lightpanda=False):
"""Common monkeypatches for local-browser readiness scenarios.
``chromium`` / ``lightpanda`` drive the runtime probes that
``_local_browser_runnable`` reuses from ``tools.browser_tool`` (lazy import,
so patching the module attributes is enough).
"""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: has_agent_browser)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: chromium)
monkeypatch.setattr(
"tools.browser_tool._using_lightpanda_engine", lambda: lightpanda
)
def test_local_browser_unavailable_without_chromium(monkeypatch):
"""agent-browser present but Chromium absent must NOT advertise local browser.
The runtime (``check_browser_requirements``) refuses local mode without a
Chromium build, so the setup/status surface must report unavailable too —
otherwise the user sees "Browser Automation available" and the first real
call fails. Regression for the false-positive setup bug.
"""
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "local"}}
)
assert features.browser.available is False
assert features.browser.active is False
assert features.browser.managed_by_nous is False
assert features.browser.current_provider == "Local browser"
def test_local_browser_available_with_chromium(monkeypatch):
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=True)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "local"}}
)
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.current_provider == "Local browser"
def test_local_browser_available_with_lightpanda_without_chromium(monkeypatch):
"""Lightpanda is text-only and needs no Chromium, so it stays available.
Guards against the fix over-correcting into a false-negative for the
legitimate Lightpanda-without-Chromium configuration.
"""
_stub_browser_probes(
monkeypatch, has_agent_browser=True, chromium=False, lightpanda=True
)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "local"}}
)
assert features.browser.available is True
assert features.browser.active is True
def test_default_local_browser_unavailable_without_chromium(monkeypatch):
"""The implicit (no cloud_provider) local fallthrough is gated on Chromium too."""
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
features = ns.get_nous_subscription_features({})
assert features.browser.available is False
assert features.browser.current_provider == "Local browser"
def test_cloud_browserbase_available_without_local_chromium(monkeypatch):
"""Cloud providers host their own Chromium, so the new local gate must not
regress them: agent-browser binary present + Browserbase creds is enough."""
env = {"BROWSERBASE_API_KEY": "bb-key", "BROWSERBASE_PROJECT_ID": "bb-project"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
# Chromium absent locally — must not matter for a cloud provider.
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: False)
monkeypatch.setattr("tools.browser_tool._using_lightpanda_engine", lambda: False)
features = ns.get_nous_subscription_features(
{"browser": {"cloud_provider": "browserbase"}}
)
assert features.browser.available is True
assert features.browser.active is True
assert features.browser.current_provider == "Browserbase"
def test_get_gateway_eligible_tools_pool_excludes_video(monkeypatch):
"""A free-tool-pool user is offered the covered tools but NOT video gen."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
{"model": {"provider": "nous"}}
)
assert set(unconfigured) == {"web", "image_gen", "tts", "stt", "browser"}
assert "video_gen" not in unconfigured
assert "video_gen" not in has_direct
assert "video_gen" not in already_managed
def test_get_gateway_eligible_tools_empty_when_not_entitled(monkeypatch):
"""A logged-in free user with no pool and no paid access gets nothing."""
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=False)
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
{"model": {"provider": "nous"}}
)
assert (unconfigured, has_direct, already_managed) == ([], [], [])
def _capture_checklist(monkeypatch, *, selected_idx):
"""Patch prompt_checklist to capture its args and return chosen indices."""
captured = {}
def _fake_checklist(title, items, pre_selected=None):
captured["title"] = title
captured["items"] = list(items)
captured["pre_selected"] = list(pre_selected or [])
return list(selected_idx)
import hermes_cli.setup as setup_mod
monkeypatch.setattr(setup_mod, "prompt_checklist", _fake_checklist, raising=False)
monkeypatch.setattr(
"hermes_cli.config.save_config", lambda cfg: None, raising=False
)
return captured
def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch):
"""Pool user's checklist lists web/image/tts/browser and never video."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
captured = _capture_checklist(monkeypatch, selected_idx=[])
config = {"model": {"provider": "nous"}}
ns.prompt_enable_tool_gateway(config)
blob = " ".join(captured["items"]).lower()
assert "firecrawl" in blob # web offered
assert "video" not in blob # video NOT offered to a pool user
# Pool-aware framing, not "subscription".
assert "free" in captured["title"].lower() and "pool" in captured["title"].lower()
def test_prompt_enable_tool_gateway_writes_only_selected(monkeypatch):
"""Selecting a subset writes use_gateway only for those tools."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
# Offered order is _ALL_GATEWAY_KEYS filtered to covered: web, image_gen, tts, browser.
# Select index 0 (web) and 1 (image_gen) only.
_capture_checklist(monkeypatch, selected_idx=[0, 1])
config = {"model": {"provider": "nous"}}
changed = ns.prompt_enable_tool_gateway(config)
assert changed == {"web", "image_gen"}
assert config["web"]["use_gateway"] is True
assert config["image_gen"]["use_gateway"] is True
assert "tts" not in config or config.get("tts", {}).get("use_gateway") is not True
assert "video_gen" not in config
def test_prompt_enable_tool_gateway_paid_user_offers_video(monkeypatch):
"""Paid users still get video gen in the offer (regression guard)."""
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
captured = _capture_checklist(monkeypatch, selected_idx=[])
ns.prompt_enable_tool_gateway({"model": {"provider": "nous"}})
blob = " ".join(captured["items"]).lower()
assert "video" in blob
def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
"""apply_nous_managed_defaults must write video_gen.provider and
video_gen.use_gateway when a Nous subscriber selects video_gen
without a direct FAL_KEY."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {"model": {"provider": "nous"}}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["video_gen"],
)
assert "video_gen" in changed
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
def test_apply_nous_managed_defaults_writes_image_gen_config(monkeypatch):
"""apply_nous_managed_defaults must write image_gen.use_gateway
when a Nous subscriber selects image_gen without a direct FAL_KEY."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {"model": {"provider": "nous"}}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["image_gen"],
)
assert "image_gen" in changed
assert config["image_gen"]["use_gateway"] is True
def test_apply_nous_managed_defaults_skips_fal_tools_when_key_present(monkeypatch):
"""When FAL_KEY is set, apply_nous_managed_defaults should not touch
image_gen or video_gen config — the user's direct key takes precedence."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.setenv("FAL_KEY", "fal-direct-key")
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {"model": {"provider": "nous"}}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["image_gen", "video_gen"],
)
assert "image_gen" not in changed
assert "video_gen" not in changed
assert "image_gen" not in config
assert "video_gen" not in config
def test_apply_nous_managed_defaults_preserves_existing_video_gen_section(monkeypatch):
"""When video_gen config already exists as a dict, the function should
update it in-place rather than replacing it."""
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
config = {
"model": {"provider": "nous"},
"video_gen": {"model": "pixverse-v6"},
}
changed = ns.apply_nous_managed_defaults(
config, enabled_toolsets=["video_gen"],
)
assert "video_gen" in changed
assert config["video_gen"]["provider"] == "fal"
assert config["video_gen"]["use_gateway"] is True
# Pre-existing keys should be preserved
assert config["video_gen"]["model"] == "pixverse-v6"
# ---------------------------------------------------------------------------
# ensure_nous_portal_access — inline login gate for `hermes tools`
# ---------------------------------------------------------------------------
def test_ensure_nous_portal_access_fast_path_when_already_paid(monkeypatch):
"""Already-entitled users return True without any login prompt."""
login_called = {"v": False}
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
def _login(**kw):
login_called["v"] = True
return True
monkeypatch.setattr(ns, "_run_nous_portal_login_only", _login)
assert ns.ensure_nous_portal_access() is True
assert login_called["v"] is False
def test_ensure_nous_portal_access_logs_in_then_grants(monkeypatch):
"""Logged-out user logs in, then entitlement re-check shows paid access."""
states = iter([
_account(logged_in=False, paid=None), # initial check
_account(logged_in=True, paid=True), # after login
])
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda **kw: next(states),
)
monkeypatch.setattr(ns, "_run_nous_portal_login_only", lambda **kw: True)
assert ns.ensure_nous_portal_access() is True
def test_ensure_nous_portal_access_returns_false_when_login_declined(monkeypatch):
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=False, paid=None),
)
monkeypatch.setattr(ns, "_run_nous_portal_login_only", lambda **kw: False)
assert ns.ensure_nous_portal_access() is False
def test_ensure_nous_portal_access_false_when_logged_in_but_unpaid(monkeypatch):
"""Logged in already but no paid access — no login attempt, returns False."""
login_called = {"v": False}
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=False),
)
def _login(**kw):
login_called["v"] = True
return True
monkeypatch.setattr(ns, "_run_nous_portal_login_only", _login)
assert ns.ensure_nous_portal_access() is False
# Already logged in, so no device-code login should be attempted.
assert login_called["v"] is False
# ---------------------------------------------------------------------------
# STT — managed-by-Nous detection (Phase 4 follow-up)
# ---------------------------------------------------------------------------
def test_stt_managed_by_nous_when_provider_openai_and_no_direct_key(monkeypatch):
"""Default `stt.provider: openai` with a Nous sub + no direct OpenAI key
should route through the managed audio gateway."""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(
ns,
"is_managed_tool_gateway_ready",
lambda vendor: vendor == "openai-audio",
)
features = ns.get_nous_subscription_features({"stt": {"provider": "openai"}})
assert features.stt.available is True
assert features.stt.active is True
assert features.stt.managed_by_nous is True
assert features.stt.direct_override is False
assert features.stt.current_provider == "OpenAI Whisper"
def test_stt_direct_key_overrides_managed(monkeypatch):
"""When the user has VOICE_TOOLS_OPENAI_KEY set, STT should use the
direct key, not the managed gateway — same precedence as TTS."""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=True, paid=True),
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "sk-direct-key")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(
ns,
"is_managed_tool_gateway_ready",
lambda vendor: vendor == "openai-audio",
)
features = ns.get_nous_subscription_features({"stt": {"provider": "openai"}})
assert features.stt.available is True
assert features.stt.managed_by_nous is False
assert features.stt.direct_override is True
def test_stt_groq_provider_requires_groq_key(monkeypatch):
env = {"GROQ_API_KEY": "groq-key"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns, "get_nous_portal_account_info",
lambda **kw: _account(logged_in=False),
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
features = ns.get_nous_subscription_features({"stt": {"provider": "groq"}})
assert features.stt.available is True
assert features.stt.managed_by_nous is False
assert features.stt.current_provider == "Groq Whisper"
assert features.stt.explicit_configured is True
def test_apply_nous_managed_defaults_flips_stt_provider_to_openai_for_nous_users(monkeypatch):
"""Fresh Nous-subscribed user with the DEFAULT_CONFIG `stt.provider: local`
seed should have it auto-flipped to "openai" so the managed audio
gateway transcribes their voice notes without needing faster-whisper
installed."""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
# CI installs [all] extras, so faster-whisper is importable there —
# force the "no local backend" case this test is about.
monkeypatch.setattr(ns, "_local_stt_backend_available", lambda: False)
# Avoid the heavy real probing in get_nous_subscription_features.
monkeypatch.setattr(
ns,
"get_nous_subscription_features",
lambda config, **kw: ns.NousSubscriptionFeatures(
subscribed=True,
nous_auth_present=True,
provider_is_nous=True,
account_info=_account(logged_in=True, paid=True),
features={
key: ns.NousFeatureState(
key=key, label=key, included_by_default=True,
available=False, active=False, managed_by_nous=False,
direct_override=False, toolset_enabled=False,
explicit_configured=False,
)
for key in ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
},
),
)
config = {"stt": {"provider": "local"}}
changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[])
assert "stt" in changed
assert config["stt"]["provider"] == "openai"
def _stt_features_stub(*, account_info):
return ns.NousSubscriptionFeatures(
subscribed=True,
nous_auth_present=True,
provider_is_nous=True,
account_info=account_info,
features={
key: ns.NousFeatureState(
key=key, label=key, included_by_default=True,
available=False, active=False, managed_by_nous=False,
direct_override=False, toolset_enabled=False,
explicit_configured=False,
)
for key in ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
},
)
def test_apply_nous_managed_defaults_keeps_local_stt_when_backend_works(monkeypatch):
"""A working local backend (faster-whisper installed or custom command)
is a strong intent signal — never flip it to the managed gateway."""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "_local_stt_backend_available", lambda: True)
monkeypatch.setattr(
ns,
"get_nous_subscription_features",
lambda config, **kw: _stt_features_stub(
account_info=_account(logged_in=True, paid=True)
),
)
config = {"stt": {"provider": "local"}}
changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[])
assert "stt" not in changed
assert config["stt"]["provider"] == "local"
def test_apply_nous_managed_defaults_skips_stt_when_not_entitled(monkeypatch):
"""A subscriber whose tool pool doesn't cover openai-audio must not be
pointed at a managed gateway that will refuse them."""
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "_local_stt_backend_available", lambda: False)
monkeypatch.setattr(
ns,
"get_nous_subscription_features",
lambda config, **kw: _stt_features_stub(
account_info=_account(logged_in=True, paid=False)
),
)
config = {"stt": {"provider": "local"}}
changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[])
assert "stt" not in changed
assert config["stt"]["provider"] == "local"
def test_apply_nous_managed_defaults_skips_stt_when_groq_key_present(monkeypatch):
"""Don't override a user who explicitly set up Groq for STT."""
env = {"GROQ_API_KEY": "groq-key"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(
ns,
"get_nous_subscription_features",
lambda config, **kw: ns.NousSubscriptionFeatures(
subscribed=True,
nous_auth_present=True,
provider_is_nous=True,
account_info=_account(logged_in=True, paid=True),
features={
key: ns.NousFeatureState(
key=key, label=key, included_by_default=True,
available=False, active=False, managed_by_nous=False,
direct_override=False, toolset_enabled=False,
explicit_configured=False,
)
for key in ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
},
),
)
config = {"stt": {"provider": "local"}}
changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[])
# STT was not flipped because the user has a Groq key configured.
assert "stt" not in changed
assert config["stt"]["provider"] == "local"
def test_apply_gateway_defaults_sets_stt_use_gateway(monkeypatch):
config = {}
changed = ns.apply_gateway_defaults(config, ["stt"])
assert "stt" in changed
assert config["stt"]["provider"] == "openai"
assert config["stt"]["use_gateway"] is True
def test_has_agent_browser_resolves_via_hermes_managed_node_path(monkeypatch, tmp_path):
"""The managed-Node rung: a runnable agent-browser under the Hermes Node
dir must count even when it's absent from the probe process's PATH (the
Windows installer shape — install succeeded, GUI still said needs setup)."""
import shutil as _shutil
managed_dir = tmp_path / "node"
managed_dir.mkdir()
managed_bin = managed_dir / "agent-browser"
managed_bin.write_text("#!/bin/sh\nexit 0\n")
managed_bin.chmod(0o755)
monkeypatch.setattr(_shutil, "which", lambda cmd, path=None: str(managed_bin) if path else None)
monkeypatch.setattr(
"hermes_constants.with_hermes_node_path", lambda: {"PATH": str(managed_dir)}
)
monkeypatch.setattr(
"hermes_constants.agent_browser_runnable",
lambda p: bool(p) and str(p) == str(managed_bin),
)
assert ns._has_agent_browser() is True
def test_has_agent_browser_false_when_nothing_runnable(monkeypatch):
import shutil as _shutil
monkeypatch.setattr(_shutil, "which", lambda cmd, path=None: None)
monkeypatch.setattr("hermes_constants.with_hermes_node_path", lambda: {"PATH": ""})
monkeypatch.setattr("hermes_constants.agent_browser_runnable", lambda p: False)
assert ns._has_agent_browser() is False