From aec50eb5979afb08af9c1c0d5e6a869412d2af47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:16:44 -0700 Subject: [PATCH] fix(live-qa): nous_subscription managed table indexes only selectable features (KeyError 'modal' crashed status/tools/dashboard toolsets); restore PluginManager public API (has_portable_mcp_servers, get_telegram_handler_factories, register_approval_transport), extract_json_candidate public name, canonical_whatsapp_identifier main semantics --- gateway/whatsapp_identity.py | 9 ++++++-- hermes_cli/nous_subscription.py | 4 +++- hermes_cli/plugins.py | 26 ++++++++++++++++++++++ tests/hermes_cli/test_nous_subscription.py | 18 +++++++++++++++ tools/delegation_output_schema.py | 4 ++-- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/gateway/whatsapp_identity.py b/gateway/whatsapp_identity.py index ba2e6f0942..f8ea45377c 100644 --- a/gateway/whatsapp_identity.py +++ b/gateway/whatsapp_identity.py @@ -87,5 +87,10 @@ def canonical_whatsapp_identifier(identifier: str) -> str: """Stable sender identity across phone-JID/LID variants (DM ``chat_id`` and group ``participant_id`` alike): the shortest alias from :func:`expand_whatsapp_aliases`, which degrades to the normalized input when no mapping files exist. ``""`` for empty input.""" - aliases = expand_whatsapp_aliases(identifier) - return min(aliases, key=lambda c: (len(c), c)) if aliases else "" + normalized = normalize_whatsapp_identifier(identifier) + if not normalized: + return "" + # expand_whatsapp_aliases includes ``normalized`` itself, so min() degrades to it + # when no lid-mapping files are present. + aliases = expand_whatsapp_aliases(normalized) + return min(aliases, key=lambda c: (len(c), c)) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index bc58db289c..a136e9da73 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -452,10 +452,12 @@ def get_nous_subscription_features(config: Optional[Dict[str, object]] = None, * use_gateway = {key: value == "nous" for key, value in selected.items()} # Managed availability per feature. A stored VENDOR selection pins the category to direct # credentials — managed availability must not light it up (the runtime errors, not reroutes). + # Features without a config selection field (modal) have no pin and read as unselected. managed = { key: ( managed_tools_flag and is_managed_tool_gateway_ready(spec.gateway) - and account_info.tool_gateway_entitled_for(spec.coverage) and (selected[key] is None or use_gateway[key]) + and account_info.tool_gateway_entitled_for(spec.coverage) + and (selected.get(key) is None or use_gateway.get(key, False)) ) for key, spec in _FEATURES.items() } diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index f4327a6871..13cc9b11e6 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1264,6 +1264,25 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin): logger.log(*verdict.log) return False + def register_approval_transport(self, name: str, present_fn: Callable, *, plugin_id: str) -> None: + """Manager-level registration (public API kept for out-of-tree plugins); the PluginContext + method is the tracked path plugins normally use. Same validation, no unload tracking.""" + from hermes_cli.approval_transport import RegisteredApprovalTransport + clean = str(name).strip().lower() + if clean == "builtin": + raise ValueError("approval transport name 'builtin' is reserved") + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", clean): + raise ValueError("approval transport name must match [a-z0-9][a-z0-9_-]{0,63}") + if not callable(present_fn): + raise TypeError("approval transport present_fn must be callable") + if clean in self._approval_transports: + owner = self._approval_transports[clean].plugin_id + raise ValueError(f"approval transport {clean!r} is already registered by {owner!r}") + self._approval_transports[clean] = RegisteredApprovalTransport( + name=clean, present=present_fn, plugin_id=plugin_id, profile_home=str(get_hermes_home().resolve()), + ) + logger.info("Plugin %s registered approval transport: %s", plugin_id, clean) + def get_approval_transport(self, name: str): """Return a transport only inside the profile that registered it.""" registered = self._approval_transports.get(str(name).strip().lower()) @@ -1323,6 +1342,10 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin): adapter)`` at connect (see :meth:`PluginContext.register_platform_handler`).""" return list(self._platform_handler_factories.get((platform or "").strip().lower(), [])) + def get_telegram_handler_factories(self) -> List[tuple]: + """Back-compat alias for ``get_platform_handler_factories("telegram")``.""" + return self.get_platform_handler_factories("telegram") + def list_plugins(self) -> List[Dict[str, Any]]: """Return a list of info dicts for all discovered plugins.""" return [ @@ -1354,6 +1377,9 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin): } for qualified, entry in sorted(self._plugin_skills.items()) ] + def has_portable_mcp_servers(self) -> bool: + return bool(self._portable_mcp_servers) + def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]: """Return a defensive copy of enabled portable MCP server configs.""" return {name: dict(config) for name, config in self._portable_mcp_servers.items()} diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index c9ffaa931a..7f18714a46 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -186,6 +186,24 @@ def _capture_checklist(monkeypatch, *, selected_idx): return captured +def test_logged_in_entitled_account_yields_a_state_for_every_feature(monkeypatch): + """The logged-in + entitled branch must produce a state for EVERY feature, including + those with no config selection field (modal). Regression: the managed-availability + table indexed the selection map by every feature key and raised KeyError('modal'), + crashing `hermes status`, `hermes tools`, and the dashboard toolsets API.""" + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account()) + monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda gateway: True) + monkeypatch.setattr(ns, "get_env_value", lambda name: "") + 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) + + result = ns.get_nous_subscription_features({"model": {"provider": "nous"}}) + + assert set(result.features) == set(ns._FEATURES) + assert result.modal.available is True # entitled + gateway ready → managed modal is offered + + 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()) diff --git a/tools/delegation_output_schema.py b/tools/delegation_output_schema.py index 1540520792..0e1a55ff9f 100644 --- a/tools/delegation_output_schema.py +++ b/tools/delegation_output_schema.py @@ -56,7 +56,7 @@ def append_output_contract(context: Optional[str], schema: Dict[str, Any]) -> st return f"{base}\n\n{block}" if base else block -def _extract_json_candidate(text: str) -> str: +def extract_json_candidate(text: str) -> str: """Strip markdown fences and prose around the outermost ``{...}``/``[...]``.""" raw = (text or "").strip() if raw.startswith("```"): @@ -78,7 +78,7 @@ def _extract_json_candidate(text: str) -> str: def validate_output(text: str, schema: Dict[str, Any]) -> Tuple[bool, List[str]]: """``(True, [])`` or ``(False, errors)`` with strings suitable for the retry turn.""" - candidate = _extract_json_candidate(text or "") + candidate = extract_json_candidate(text or "") if not candidate.strip(): return False, ["Response was empty — expected a JSON object matching the schema."] try: