diff --git a/agent/account_usage.py b/agent/account_usage.py index 2795eb24125..da02af3c478 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: account info to show (fail-open: caller just shows nothing). """ try: - from hermes_cli.nous_account import nous_portal_billing_url + from hermes_cli.nous_account import nous_portal_topup_url if account_info is None or not getattr(account_info, "logged_in", False): return None @@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: if not windows and not details: return None - details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}") + details.append(f"Top up: {nous_portal_topup_url(account_info)}") + details.append("(or run /credits)") plan = getattr(sub, "plan", None) if sub is not None else None return AccountUsageSnapshot( @@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]: return None +@dataclass(frozen=True) +class CreditsView: + """Surface-agnostic data for the ``/credits`` command. + + One portal fetch, one parse — consumed identically by the CLI panel, the + gateway button, and any other money surface. Fail-open: when not logged in + or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None + and callers degrade gracefully. + """ + + logged_in: bool + balance_lines: tuple[str, ...] = () + identity_line: Optional[str] = None + topup_url: Optional[str] = None + depleted: bool = False + + +def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView: + """Build the /credits view: balance block + identity line + top-up URL. + + Reuses the same account fetch + snapshot + URL builder as the /usage credits + block, so the numbers always match. The balance block is the rendered + snapshot MINUS its trailing top-up/command-hint lines (the /credits surface + supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``. + """ + not_logged_in = CreditsView(logged_in=False) + try: + from hermes_cli.auth import get_provider_auth_state + + tok = (get_provider_auth_state("nous") or {}).get("access_token") + if not (isinstance(tok, str) and tok.strip()): + return not_logged_in + except Exception: + return not_logged_in + + try: + import concurrent.futures + + from hermes_cli.nous_account import ( + get_nous_portal_account_info, + nous_portal_topup_url, + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + account = pool.submit(get_nous_portal_account_info, force_fresh=True).result( + timeout=timeout + ) + except Exception: + logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True) + return not_logged_in + + if account is None or not getattr(account, "logged_in", False): + return not_logged_in + + snapshot = build_nous_credits_snapshot(account) + # Balance lines = the snapshot block minus the two trailing affordance lines + # ("Top up: " + "(or run /credits)") that build_nous_credits_snapshot + # appends for the /usage surface. /credits renders its own button/panel. + balance_lines: list[str] = [] + if snapshot is not None: + rendered = render_account_usage_lines(snapshot, markdown=markdown) + balance_lines = [ + line + for line in rendered + if not line.lstrip().startswith("Top up:") + and not line.lstrip().startswith("(or run") + ] + + # Identity line — shown before any open (roadmap §4.4). + email = getattr(account, "email", None) + org_name = getattr(account, "org_name", None) + who: list[str] = [] + if email: + who.append(str(email)) + if org_name: + who.append(f"org {org_name}") + identity_line = ("Topping up as " + " / ".join(who)) if who else None + + return CreditsView( + logged_in=True, + balance_lines=tuple(balance_lines), + identity_line=identity_line, + topup_url=nous_portal_topup_url(account), + depleted=getattr(account, "paid_service_access", None) is False, + ) + + def _resolve_codex_usage_url(base_url: str) -> str: normalized = (base_url or "").strip().rstrip("/") if not normalized: diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 7268a105aa8..19e5b1582da 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -355,7 +355,7 @@ def evaluate_credits_notices( if show_depleted and "credits.depleted" not in active: to_show.append( AgentNotice( - text="✕ Credit access paused · run /usage for balance", + text="✕ Credit access paused · run /credits to top up", level="error", kind=CREDITS_NOTICE_KIND, key="credits.depleted", diff --git a/cli.py b/cli.py index 0212ed9e2f3..bc5ced017ad 100644 --- a/cli.py +++ b/cli.py @@ -7451,6 +7451,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._manual_compress(cmd_original) elif canonical == "usage": self._show_usage() + elif canonical == "credits": + self._show_credits() elif canonical == "insights": self._show_insights(cmd_original) elif canonical == "copy": @@ -8352,6 +8354,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): print(f" {line}") return True + def _show_credits(self): + """`/credits` — focused Nous credit balance + top-up handoff. + + Interactive CLI: balance block + identity line + a 3-button panel + (Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI + slash-worker subprocess and any place without a live prompt_toolkit app + (``self._app is None``) — render a text variant (balance + tappable + top-up URL), because the modal would try to read the RPC stdin and crash + the worker. The terminal never confirms or polls payment (billing phase + 2a). Fail-open: a portal hiccup or logged-out account degrades to a clear + message, never a crash. + """ + from agent.account_usage import build_credits_view + + view = build_credits_view() + + if not view.logged_in: + print() + print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}") + print(" Run `hermes portal` to log in, then /credits.") + return + + print() + print(" 💳 Nous credits") + print(f" {'─' * 41}") + for line in view.balance_lines: + # Drop the helper's own "📈 Nous credits" header — we print our own. + if line.lstrip().startswith("📈"): + continue + print(f" {line}") + print(f" {'─' * 41}") + if view.identity_line: + print(f" {view.identity_line}") + + if not view.topup_url: + return + + # Non-interactive (TUI slash-worker, piped, no live app): the + # prompt_toolkit modal can't run here — it would read the worker's + # JSON-RPC stdin and crash the command. Render the text variant: the + # tappable URL IS the affordance, same as the messaging surfaces. + if not getattr(self, "_app", None): + print() + print(f" Top up: {view.topup_url}") + print(" Complete your top-up in the browser — credits will appear in /credits shortly.") + return + + choices = [ + ("open", "Open top-up in browser", "launch the portal billing page"), + ("copy", "Copy link", "copy the top-up URL to your clipboard"), + ("cancel", "Cancel", "do nothing"), + ] + raw = self._prompt_text_input_modal( + title="💳 Add credits?", + detail=f"Top-up page:\n{view.topup_url}", + choices=choices, + ) + choice = self._normalize_slash_confirm_choice(raw, choices) + + if choice == "open": + opened = False + try: + import webbrowser + + opened = webbrowser.open(view.topup_url) + except Exception: + opened = False + if not opened: + print(f" Open this URL to top up: {view.topup_url}") + print() + print(" Complete your top-up in the browser — credits will appear in /credits shortly.") + elif choice == "copy": + try: + self._write_osc52_clipboard(view.topup_url) + print(f" 📋 Copied: {view.topup_url}") + except Exception: + print(f" Top-up URL: {view.topup_url}") + else: + print(" 🟡 Cancelled. No credits added.") + def _show_insights(self, command: str = "/insights"): """Show usage insights and analytics from session history.""" # Parse optional --days flag diff --git a/gateway/run.py b/gateway/run.py index 27fc7905095..e2a1b8839c3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7278,6 +7278,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "usage": return await self._handle_usage_command(event) + if canonical == "credits": + return await self._handle_credits_command(event) + if canonical == "insights": return await self._handle_insights_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 1bb2fc41d1c..9a463fd249d 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2942,6 +2942,40 @@ class GatewaySlashCommandsMixin: key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many" return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id) + async def _handle_credits_command(self, event: MessageEvent) -> str: + """Handle /credits -- show Nous credit balance and the top-up handoff. + + Renders the balance block + identity line + a tappable top-up URL that + opens the portal billing page with the modal open. The terminal does NOT + confirm, poll, or track payment (billing phase 2a) — checkout happens in + the browser and the next /credits shows the new balance. The tappable URL + is the affordance: it works on every platform (button-capable or plain + text like SMS/email). Fetched off the event loop; fail-open. + """ + from agent.account_usage import build_credits_view + + try: + view = await asyncio.to_thread(build_credits_view, markdown=True) + except Exception: + view = None + + if view is None or not view.logged_in: + return t("gateway.credits.not_logged_in") + + lines: list[str] = ["💳 **Nous credits**"] + for line in view.balance_lines: + if line.lstrip().startswith("📈"): + continue # drop the helper's header; we print our own + lines.append(line) + if view.identity_line: + lines.append("") + lines.append(view.identity_line) + if view.topup_url: + lines.append("") + lines.append(f"Top up: {view.topup_url}") + lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.") + return "\n".join(lines) + async def _handle_usage_command(self, event: MessageEvent) -> str: """Handle /usage command -- show token usage for the current session. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 78461ca138e..7812eba7d5f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -214,6 +214,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session", gateway_only=True), CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), + CommandDef("credits", "Show Nous credit balance and top up", "Info"), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", @@ -1043,6 +1044,17 @@ _SLACK_RESERVED_COMMANDS = frozenset({ # native slot, the alias spelling stays reachable via /hermes reset). _SLACK_PRIORITY_ALIASES = ("btw", "bg") +# Canonical commands intentionally NOT given a native Slack slash slot. Slack +# caps apps at 50 slash commands and the registry is at that ceiling; rather +# than let the clamp silently drop whichever command sorts last (and break +# Telegram parity), we explicitly route a few low-frequency commands through +# ``/hermes `` on Slack only. They remain native on every other +# surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional — +# the telegram-parity test reads it so an entry here is a deliberate +# "Slack-via-/hermes" decision, not a silent clamp. +# - credits: the billing/top-up surface; reached via /hermes credits on Slack. +_SLACK_VIA_HERMES_ONLY = frozenset({"credits"}) + def _sanitize_slack_name(raw: str) -> str: """Convert a command name to a valid Slack slash command name. @@ -1091,6 +1103,9 @@ def slack_native_slashes() -> list[tuple[str, str, str]]: return if slack_name in _SLACK_RESERVED_COMMANDS: return + if slack_name in _SLACK_VIA_HERMES_ONLY: + # Intentionally Slack-via-/hermes only (see _SLACK_VIA_HERMES_ONLY). + return if len(entries) >= _SLACK_MAX_SLASH_COMMANDS: return # Slack description cap is 2000 chars; keep it short. diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index e28c04d4ae3..c604512afd6 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -80,6 +80,8 @@ class NousPortalAccountInfo: fresh: bool user_id: Optional[str] = None org_id: Optional[str] = None + org_slug: Optional[str] = None + org_name: Optional[str] = None client_id: Optional[str] = None product_id: Optional[str] = None nous_client: Optional[str] = None @@ -140,6 +142,29 @@ def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None return f"{base.rstrip('/')}/billing" +def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None) -> str: + """Return the portal top-up URL that auto-opens the top-up modal. + + Prefers the org-pinned page ``{base}/orgs/{slug}/billing?topup=open`` (skips + the legacy shim's re-resolution + multi-org disambiguation). Falls back to the + legacy ``{base}/billing?topup=open`` when the account has no ``org_slug`` (the + portal's ``slug`` is nullable; the legacy page forwards the param through to + the org-pinned page). Never builds ``/orgs/None/billing``. + + The ``?topup=open`` query is the NAS enabler that lands the user in the + top-up flow rather than just on the billing page. + """ + base_billing = nous_portal_billing_url(account_info) # {base}/billing + base = base_billing[: -len("/billing")] # strip the trailing /billing + + slug = getattr(account_info, "org_slug", None) if account_info is not None else None + if isinstance(slug, str) and slug.strip(): + from urllib.parse import quote + + return f"{base}/orgs/{quote(slug.strip(), safe='')}/billing?topup=open" + return f"{base}/billing?topup=open" + + def format_nous_portal_entitlement_message( account_info: Optional[NousPortalAccountInfo], *, @@ -607,12 +632,10 @@ def _info_from_account_payload( state: dict[str, Any], portal_base_url: Optional[str], ) -> NousPortalAccountInfo: - user = payload.get("user") if isinstance(payload.get("user"), dict) else {} - organisation = ( - payload.get("organisation") - if isinstance(payload.get("organisation"), dict) - else {} - ) + raw_user = payload.get("user") + user: dict[str, Any] = raw_user if isinstance(raw_user, dict) else {} + raw_org = payload.get("organisation") + organisation: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {} subscription = _subscription_from_payload(payload.get("subscription")) access = _paid_service_access_from_payload(payload.get("paid_service_access")) paid_access = access.allowed if access else None @@ -624,6 +647,8 @@ def _info_from_account_payload( source="account_api", fresh=True, org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None), + org_slug=_coerce_str(organisation.get("slug")), + org_name=_coerce_str(organisation.get("name")), client_id=_coerce_str(state.get("client_id")), portal_base_url=portal_base_url, inference_base_url=_coerce_str(state.get("inference_base_url")), diff --git a/locales/af.yaml b/locales/af.yaml index 1ac315a1d4d..7a01f51983c 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Gedetailleerde gebruik beskikbaar na die eerste agent-antwoord)_" no_data: "Geen gebruiksdata beskikbaar vir hierdie sessie nie." + credits: + not_logged_in: "Nie by Nous Portal aangemeld nie. Meld aan om jou kredietsaldo te sien en op te laai." + verbose: not_enabled: "Die `/verbose`-opdrag is nie vir boodskapplatforms geaktiveer nie.\n\nAktiveer dit in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Gereedskap-vordering: **AF** — geen gereedskap-aktiwiteit word vertoon nie." diff --git a/locales/de.yaml b/locales/de.yaml index f83181c9815..f3414c1df3b 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Detaillierte Nutzung nach der ersten Agentenantwort verfügbar)_" no_data: "Keine Nutzungsdaten für diese Sitzung verfügbar." + credits: + not_logged_in: "Nicht bei Nous Portal angemeldet. Melde dich an, um dein Guthaben zu sehen und aufzuladen." + verbose: not_enabled: "Der Befehl `/verbose` ist für Messaging-Plattformen nicht aktiviert.\n\nIn `config.yaml` aktivieren:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Tool-Fortschritt: **OFF** — keine Tool-Aktivität angezeigt." diff --git a/locales/en.yaml b/locales/en.yaml index acf15ae1a12..00a7654f4f0 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -346,6 +346,9 @@ gateway: detailed_after_first: "_(Detailed usage available after the first agent response)_" no_data: "No usage data available for this session." + credits: + not_logged_in: "Not logged into Nous Portal. Log in to see your credit balance and top up." + verbose: not_enabled: "The `/verbose` command is not enabled for messaging platforms.\n\nEnable it in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Tool progress: **OFF** — no tool activity shown." diff --git a/locales/es.yaml b/locales/es.yaml index 429f9f0f987..96967d95632 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Uso detallado disponible tras la primera respuesta del agente)_" no_data: "No hay datos de uso disponibles para esta sesión." + credits: + not_logged_in: "No has iniciado sesión en Nous Portal. Inicia sesión para ver tu saldo de créditos y recargar." + verbose: not_enabled: "El comando `/verbose` no está habilitado para plataformas de mensajería.\n\nHabilítalo en `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Progreso de herramientas: **OFF** — no se muestra actividad de herramientas." diff --git a/locales/fr.yaml b/locales/fr.yaml index ad17ee61fa9..6185f79ec52 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Utilisation détaillée disponible après la première réponse de l'agent)_" no_data: "Aucune donnée d'utilisation disponible pour cette session." + credits: + not_logged_in: "Non connecté à Nous Portal. Connecte-toi pour voir ton solde de crédits et recharger." + verbose: not_enabled: "La commande `/verbose` n'est pas activée pour les plateformes de messagerie.\n\nActivez-la dans `config.yaml` :\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Progression des outils : **OFF** — aucune activité d'outil affichée." diff --git a/locales/ga.yaml b/locales/ga.yaml index 8acb02e3814..752e3266053 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -338,6 +338,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Úsáid mhionsonraithe ar fáil tar éis chéad fhreagra an ghníomhaire)_" no_data: "Níl aon sonraí úsáide ar fáil don seisiún seo." + credits: + not_logged_in: "Níl tú logáilte isteach i Nous Portal. Logáil isteach chun d'iarmhéid creidmheasa a fheiceáil agus breis a chur leis." + verbose: not_enabled: "Níl an t-ordú `/verbose` cumasaithe d'ardáin teachtaireachtaí.\n\nCumasaigh in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Dul chun cinn uirlise: **AS** — gan aon ghníomhaíocht uirlise á thaispeáint." diff --git a/locales/hu.yaml b/locales/hu.yaml index 8afe07bba47..55d57698364 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(A részletes használat az első ügynökválasz után érhető el)_" no_data: "Ehhez a munkamenethez nincsenek elérhető használati adatok." + credits: + not_logged_in: "Nincs bejelentkezve a Nous Portalra. Jelentkezz be a kreditegyenleg megtekintéséhez és feltöltéséhez." + verbose: not_enabled: "A `/verbose` parancs nincs engedélyezve az üzenetküldő platformokon.\n\nEngedélyezd a `config.yaml` fájlban:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Eszközfolyamat: **OFF** — nem jelenik meg eszközaktivitás." diff --git a/locales/it.yaml b/locales/it.yaml index 2e355c94c68..82cf4ce8500 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(L'uso dettagliato sarà disponibile dopo la prima risposta dell'agente)_" no_data: "Nessun dato di utilizzo disponibile per questa sessione." + credits: + not_logged_in: "Non hai effettuato l'accesso a Nous Portal. Accedi per vedere il saldo dei crediti e ricaricare." + verbose: not_enabled: "Il comando `/verbose` non è abilitato per le piattaforme di messaggistica.\n\nAbilitalo in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Progresso strumenti: **OFF** — nessuna attività degli strumenti mostrata." diff --git a/locales/ja.yaml b/locales/ja.yaml index d860684acf2..4aeee2a4cf3 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(詳細な使用状況は最初のエージェント応答後に利用可能)_" no_data: "このセッションの使用データはありません。" + credits: + not_logged_in: "Nous Portal にログインしていません。ログインすると残高の確認とチャージができます。" + verbose: not_enabled: "`/verbose` コマンドはメッセージングプラットフォームで有効になっていません。\n\n`config.yaml` で有効にしてください:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ ツール進捗: **OFF** — ツールの動作は表示されません。" diff --git a/locales/ko.yaml b/locales/ko.yaml index 0966fb22ce2..8af6b28fe7a 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(자세한 사용량은 첫 에이전트 응답 이후 확인할 수 있습니다)_" no_data: "이 세션에 사용 가능한 사용량 데이터가 없습니다." + credits: + not_logged_in: "Nous Portal에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다." + verbose: not_enabled: "`/verbose` 명령은 메시징 플랫폼에서 활성화되어 있지 않습니다.\n\n`config.yaml`에서 활성화하세요:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ 도구 진행 상황: **OFF** — 도구 활동이 표시되지 않습니다." diff --git a/locales/pt.yaml b/locales/pt.yaml index fa74c6f90e9..69bdb14a9bc 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Utilização detalhada disponível após a primeira resposta do agente)_" no_data: "Não há dados de utilização disponíveis para esta sessão." + credits: + not_logged_in: "Você não está conectado ao Nous Portal. Faça login para ver seu saldo de créditos e recarregar." + verbose: not_enabled: "O comando `/verbose` não está ativado para plataformas de mensagens.\n\nAtiva-o em `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Progresso de ferramentas: **OFF** — não é mostrada qualquer atividade de ferramentas." diff --git a/locales/ru.yaml b/locales/ru.yaml index 979601aedaa..a105f1e68aa 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Подробное использование доступно после первого ответа агента)_" no_data: "Данные об использовании для этого сеанса отсутствуют." + credits: + not_logged_in: "Вы не вошли в Nous Portal. Войдите, чтобы увидеть баланс кредитов и пополнить его." + verbose: not_enabled: "Команда `/verbose` не включена для платформ обмена сообщениями.\n\nВключите в `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Прогресс инструментов: **OFF** — активность инструментов не показывается." diff --git a/locales/tr.yaml b/locales/tr.yaml index 259e56fa273..49e8fdc454e 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Ayrıntılı kullanım, ilk ajan yanıtından sonra kullanılabilir)_" no_data: "Bu oturum için kullanım verisi yok." + credits: + not_logged_in: "Nous Portal'a giriş yapılmadı. Bakiyenizi görmek ve yükleme yapmak için giriş yapın." + verbose: not_enabled: "`/verbose` komutu mesajlaşma platformlarında etkin değil.\n\n`config.yaml` içinde etkinleştirin:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Araç ilerlemesi: **OFF** — araç etkinliği gösterilmez." diff --git a/locales/uk.yaml b/locales/uk.yaml index 8f7d10ebfb7..2fa55c14c92 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(Детальне використання доступне після першої відповіді агента)_" no_data: "Дані про використання для цього сеансу відсутні." + credits: + not_logged_in: "Ви не ввійшли в Nous Portal. Увійдіть, щоб переглянути баланс кредитів і поповнити його." + verbose: not_enabled: "Команду `/verbose` не ввімкнено для платформ обміну повідомленнями.\n\nУвімкніть у `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ Прогрес інструментів: **OFF** — активність інструментів не показується." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 982a9b2918b..fd1729203f3 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(首次代理回應後可檢視詳細使用情況)_" no_data: "此工作階段沒有可用的使用資料。" + credits: + not_logged_in: "未登入 Nous Portal。登入後即可查看額度餘額並儲值。" + verbose: not_enabled: "`/verbose` 指令未在訊息平台上啟用。\n\n請在 `config.yaml` 中啟用:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ 工具進度:**OFF** — 不顯示任何工具活動。" diff --git a/locales/zh.yaml b/locales/zh.yaml index ee20289e16d..17b74e4688f 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -334,6 +334,9 @@ Future messages in this room will use that transcript until `/reset` or another detailed_after_first: "_(首次代理响应后可查看详细使用情况)_" no_data: "此会话暂无使用数据。" + credits: + not_logged_in: "未登录 Nous Portal。登录后即可查看额度余额并充值。" + verbose: not_enabled: "`/verbose` 命令未在消息平台启用。\n\n请在 `config.yaml` 中启用:\n```yaml\ndisplay:\n tool_progress_command: true\n```" mode_off: "⚙️ 工具进度:**OFF** — 不显示任何工具活动。" diff --git a/tests/agent/test_credits_policy.py b/tests/agent/test_credits_policy.py index 3f13c978268..e73b8fae2f1 100644 --- a/tests/agent/test_credits_policy.py +++ b/tests/agent/test_credits_policy.py @@ -464,12 +464,12 @@ class TestNoticeCopy: assert "$12.34" in grant_notice.text assert "top-up left" in grant_notice.text - def test_depleted_mentions_usage_command(self): + def test_depleted_mentions_credits_command(self): latch = fresh_latch() s = CreditsState(paid_access=False) to_show, _ = evaluate_credits_notices(s, latch) depleted_notice = next(n for n in to_show if n.key == "credits.depleted") - assert "/usage" in depleted_notice.text + assert "/credits" in depleted_notice.text # ── Scenario 8: severity order in a single call ────────────────────────────── diff --git a/tests/agent/test_credits_view.py b/tests/agent/test_credits_view.py new file mode 100644 index 00000000000..04aab21fe62 --- /dev/null +++ b/tests/agent/test_credits_view.py @@ -0,0 +1,260 @@ +"""Tests for the /credits command — shared view core + gateway handler. + +`/credits` is the focused money surface (balance in, top-up out). These tests +exercise the surface-agnostic `build_credits_view()` core and assert the gateway +handler renders the block + tappable top-up URL + no-wait copy. The CLI panel is +a thin wrapper over the same view (interactive prompt_toolkit modal — covered by +the view-core tests plus manual verification). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import agent.account_usage as account_usage +from agent.account_usage import CreditsView, build_credits_view +from hermes_cli.nous_account import NousPortalAccountInfo, NousPaidServiceAccessInfo + + +def _account(**kwargs) -> NousPortalAccountInfo: + kwargs.setdefault("logged_in", True) + kwargs.setdefault("source", "account_api") + kwargs.setdefault("fresh", True) + kwargs.setdefault("portal_base_url", "https://portal.example.test") + return NousPortalAccountInfo(**kwargs) + + +@pytest.fixture +def _logged_in_account(monkeypatch): + """Stub the auth token + account fetch so build_credits_view runs offline.""" + monkeypatch.setattr( + "hermes_cli.auth.get_provider_auth_state", + lambda provider: {"access_token": "tok", "portal_base_url": "https://portal.example.test"}, + ) + + def _install(account): + monkeypatch.setattr( + "hermes_cli.nous_account.get_nous_portal_account_info", + lambda *a, **kw: account, + ) + + return _install + + +# ── build_credits_view core ───────────────────────────────────────────────── + + +def test_view_logged_out_when_no_token(monkeypatch): + monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {}) + view = build_credits_view() + assert view == CreditsView(logged_in=False) + + +def test_view_built_with_org_pinned_url_and_identity(_logged_in_account): + _logged_in_account( + _account( + org_slug="acme", + org_name="Acme Inc", + email="alice@example.test", + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + purchased_credits_remaining=30.0, + total_usable_credits=30.0, + ), + subscription=None, + ) + ) + + view = build_credits_view() + + assert view.logged_in is True + assert view.topup_url == "https://portal.example.test/orgs/acme/billing?topup=open" + assert view.identity_line == "Topping up as alice@example.test / org Acme Inc" + assert view.depleted is False + # Balance lines carry the magnitudes but NOT the /usage affordance lines. + blob = "\n".join(view.balance_lines) + assert "Top-up credits: $30.00" in blob + assert "Top up:" not in blob # the trailing /usage affordance is stripped + assert "(or run" not in blob + + +def test_view_depleted_flag(_logged_in_account): + _logged_in_account( + _account( + org_slug="acme", + email="alice@example.test", + paid_service_access=False, + paid_service_access_info=NousPaidServiceAccessInfo( + total_usable_credits=0.0, + ), + subscription=None, + ) + ) + + view = build_credits_view() + assert view.depleted is True + + +def test_view_falls_back_to_legacy_url_when_slug_null(_logged_in_account): + _logged_in_account( + _account( + org_slug=None, + email="alice@example.test", + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + purchased_credits_remaining=5.0, + total_usable_credits=5.0, + ), + subscription=None, + ) + ) + + view = build_credits_view() + assert view.topup_url == "https://portal.example.test/billing?topup=open" + assert "/orgs/" not in view.topup_url + + +def test_view_fetch_failure_is_logged_out(monkeypatch): + monkeypatch.setattr( + "hermes_cli.auth.get_provider_auth_state", + lambda provider: {"access_token": "tok"}, + ) + + def _boom(*a, **kw): + raise RuntimeError("portal down") + + monkeypatch.setattr("hermes_cli.nous_account.get_nous_portal_account_info", _boom) + + view = build_credits_view() + assert view.logged_in is False + + +# ── gateway _handle_credits_command ───────────────────────────────────────── + + +class _FakeEvent: + pass + + +def _make_gateway_stub(): + """Minimal object exposing the mixin's _handle_credits_command.""" + from gateway.slash_commands import GatewaySlashCommandsMixin + + class _Stub(GatewaySlashCommandsMixin): + def __init__(self): + pass + + return _Stub() + + +def test_gateway_credits_renders_block_and_url(monkeypatch): + view = CreditsView( + logged_in=True, + balance_lines=("📈 Nous credits", "Total usable: $52.50"), + identity_line="Topping up as alice@example.test / org Acme", + topup_url="https://portal.example.test/orgs/acme/billing?topup=open", + depleted=False, + ) + monkeypatch.setattr(account_usage, "build_credits_view", lambda *a, **kw: view) + + stub = _make_gateway_stub() + out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + + assert "💳" in out + assert "Total usable: $52.50" in out + assert "Topping up as alice@example.test / org Acme" in out + assert "https://portal.example.test/orgs/acme/billing?topup=open" in out + assert "credits will appear in /credits shortly" in out + # The helper's own 📈 header line is dropped (we render our own 💳 header). + assert "📈 Nous credits" not in out + + +def test_gateway_credits_not_logged_in(monkeypatch): + monkeypatch.setattr( + account_usage, "build_credits_view", lambda *a, **kw: CreditsView(logged_in=False) + ) + stub = _make_gateway_stub() + out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + assert "Not logged into Nous Portal" in out + + +def test_gateway_credits_fetch_exception_is_not_logged_in(monkeypatch): + def _boom(*a, **kw): + raise RuntimeError("boom") + + monkeypatch.setattr(account_usage, "build_credits_view", _boom) + stub = _make_gateway_stub() + out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + assert "Not logged into Nous Portal" in out + + +# ── command registry ──────────────────────────────────────────────────────── + + +def test_credits_command_registered(): + from hermes_cli.commands import resolve_command, COMMAND_REGISTRY + + cmd = resolve_command("credits") + assert cmd is not None and cmd.name == "credits" + # Available on every surface (not cli_only / gateway_only). + entry = next(c for c in COMMAND_REGISTRY if c.name == "credits") + assert entry.cli_only is False + assert entry.gateway_only is False + + +# ── CLI _show_credits non-interactive (TUI slash-worker) path ─────────────── + + +def test_cli_show_credits_non_interactive_renders_text_not_modal(monkeypatch, capsys): + """In the TUI slash-worker (no self._app), /credits must render the text + variant — never invoke the prompt_toolkit modal, which would read the + worker's JSON-RPC stdin and crash the command (only the depleted banner + would survive). Regression for that exact failure. + """ + import agent.account_usage as account_usage + from cli import HermesCLI + + monkeypatch.setattr( + account_usage, + "build_credits_view", + lambda *a, **k: CreditsView( + logged_in=True, + balance_lines=("📈 Nous credits", "Total usable: $0.00"), + identity_line="Topping up as a@b.c / org Acme", + topup_url="https://prev.test/orgs/acme/billing?topup=open", + depleted=True, + ), + ) + + cli = HermesCLI.__new__(HermesCLI) + cli._app = None # non-interactive, like the slash worker + + # Must NOT call the modal in this context. + def _boom_modal(*a, **k): + raise AssertionError("modal must not run without a live app") + + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False) + + cli._show_credits() + + out = capsys.readouterr().out + assert "💳 Nous credits" in out + assert "Total usable: $0.00" in out + assert "Topping up as a@b.c / org Acme" in out + assert "https://prev.test/orgs/acme/billing?topup=open" in out + assert "credits will appear in /credits shortly" in out + + +def test_cli_show_credits_logged_out(monkeypatch, capsys): + import agent.account_usage as account_usage + from cli import HermesCLI + + monkeypatch.setattr( + account_usage, "build_credits_view", lambda *a, **k: CreditsView(logged_in=False) + ) + cli = HermesCLI.__new__(HermesCLI) + cli._app = None + cli._show_credits() + assert "Not logged into Nous Portal" in capsys.readouterr().out diff --git a/tests/agent/test_nous_credits_snapshot.py b/tests/agent/test_nous_credits_snapshot.py index 764c83e20df..d2c7e177587 100644 --- a/tests/agent/test_nous_credits_snapshot.py +++ b/tests/agent/test_nous_credits_snapshot.py @@ -124,3 +124,42 @@ def test_never_raises_empty(): ) # No usable numbers and not depleted -> None, without raising. assert build_nous_credits_snapshot(info) is None + + +def test_topup_line_is_org_pinned_when_slug_present(): + info = _account( + portal_base_url="https://portal.example.test", + org_slug="acme", + org_name="Acme Inc", + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + purchased_credits_remaining=30.0, + total_usable_credits=30.0, + ), + subscription=None, + ) + snap = build_nous_credits_snapshot(info) + assert snap is not None + blob = "\n".join(_all_lines(snap)) + # The /usage top-up link auto-opens the modal and is org-pinned. + assert "https://portal.example.test/orgs/acme/billing?topup=open" in blob + assert "/credits" in blob + + +def test_topup_line_falls_back_to_legacy_when_slug_null(): + info = _account( + portal_base_url="https://portal.example.test", + org_slug=None, + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + purchased_credits_remaining=30.0, + total_usable_credits=30.0, + ), + subscription=None, + ) + snap = build_nous_credits_snapshot(info) + assert snap is not None + blob = "\n".join(_all_lines(snap)) + # Null slug → legacy page (which forwards the param); never /orgs/None/... + assert "https://portal.example.test/billing?topup=open" in blob + assert "/orgs/" not in blob diff --git a/tests/gateway/test_notice_rendering.py b/tests/gateway/test_notice_rendering.py index ca35497fa0e..89a5435a507 100644 --- a/tests/gateway/test_notice_rendering.py +++ b/tests/gateway/test_notice_rendering.py @@ -28,9 +28,9 @@ class TestRenderNoticeLine: ) assert ( render_notice_line( - AgentNotice(text="✕ Credit access paused · run /usage for balance", level="error") + AgentNotice(text="✕ Credit access paused · run /credits to top up", level="error") ) - == "✕ Credit access paused · run /usage for balance" + == "✕ Credit access paused · run /credits to top up" ) def test_does_not_prepend_a_second_glyph(self): diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 0954ccf790d..72d8b5e7c37 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -14,6 +14,7 @@ from hermes_cli.commands import ( SlashCommandCompleter, _CMD_NAME_LIMIT, _SLACK_RESERVED_COMMANDS, + _SLACK_VIA_HERMES_ONLY, _TG_NAME_LIMIT, _clamp_command_names, _clamp_telegram_names, @@ -378,7 +379,10 @@ class TestSlackNativeSlashes: slack_norm = {_norm(n) for n in slack_names} tg_norm = {_norm(n) for n in tg_names} reserved_norm = {_norm(n) for n in _SLACK_RESERVED_COMMANDS} - missing = (tg_norm - slack_norm) - reserved_norm + # Commands deliberately routed through /hermes on Slack only + # (Slack's 50-slash cap) are expected to be absent from native slashes. + via_hermes_norm = {_norm(n) for n in _SLACK_VIA_HERMES_ONLY} + missing = (tg_norm - slack_norm) - reserved_norm - via_hermes_norm assert not missing, ( f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}" ) diff --git a/tests/hermes_cli/test_nous_account.py b/tests/hermes_cli/test_nous_account.py index 9610f7a6b6a..0b8ac986be3 100644 --- a/tests/hermes_cli/test_nous_account.py +++ b/tests/hermes_cli/test_nous_account.py @@ -14,6 +14,7 @@ from hermes_cli.nous_account import ( NousPortalAccountInfo, format_nous_portal_entitlement_message, get_nous_portal_account_info, + nous_portal_topup_url, reset_nous_portal_account_info_cache, ) @@ -545,3 +546,89 @@ def test_entitlement_message_for_account_missing(): assert message is not None assert "could not find a Nous Portal account or organisation" in message + + +# ── org slug/name parsing + top-up URL builder ────────────────────────────── + + +def test_account_payload_parses_org_slug_and_name(monkeypatch): + token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900}) + payload = { + "user": {"email": "alice@example.test"}, + "organisation": {"id": "org_123", "slug": "acme", "name": "Acme Inc"}, + "paid_service_access": {"allowed": True, "paid_access": True}, + } + monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token)) + monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token") + monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload) + + info = get_nous_portal_account_info(force_fresh=True) + + assert info.source == "account_api" + assert info.org_slug == "acme" + assert info.org_name == "Acme Inc" + + +def test_account_payload_org_without_slug_leaves_fields_none(monkeypatch): + # Mirrors current main: organisation: { id } only (slug nullable on the portal). + token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900}) + payload = { + "user": {"email": "alice@example.test"}, + "organisation": {"id": "org_123"}, + "paid_service_access": {"allowed": True, "paid_access": True}, + } + monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token)) + monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token") + monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload) + + info = get_nous_portal_account_info(force_fresh=True) + + assert info.org_id == "org_123" + assert info.org_slug is None + assert info.org_name is None + + +def test_topup_url_is_org_pinned_when_slug_present(): + info = NousPortalAccountInfo( + logged_in=True, + source="account_api", + fresh=True, + portal_base_url="https://portal.example.test", + org_slug="acme", + ) + assert ( + nous_portal_topup_url(info) + == "https://portal.example.test/orgs/acme/billing?topup=open" + ) + + +def test_topup_url_falls_back_to_legacy_when_slug_null(): + info = NousPortalAccountInfo( + logged_in=True, + source="account_api", + fresh=True, + portal_base_url="https://portal.example.test", + org_slug=None, + ) + url = nous_portal_topup_url(info) + assert url == "https://portal.example.test/billing?topup=open" + assert "/orgs/" not in url + + +def test_topup_url_strips_trailing_slash_and_encodes_slug(): + info = NousPortalAccountInfo( + logged_in=True, + source="account_api", + fresh=True, + portal_base_url="https://portal.example.test/", + org_slug="a/b team", + ) + assert ( + nous_portal_topup_url(info) + == "https://portal.example.test/orgs/a%2Fb%20team/billing?topup=open" + ) + + +def test_topup_url_defaults_to_production_portal_for_none(): + url = nous_portal_topup_url(None) + assert url == "https://portal.nousresearch.com/billing?topup=open" diff --git a/tests/hermes_cli/test_portal_cli.py b/tests/hermes_cli/test_portal_cli.py deleted file mode 100644 index 927661ef5bc..00000000000 --- a/tests/hermes_cli/test_portal_cli.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for `hermes portal` dispatch. - -`hermes portal` (no subcommand) is the human-readable alias for the Nous Portal -one-shot onboarding (`hermes auth add nous --type oauth` / `hermes setup ---portal`). The prior status default moved to `hermes portal info`, with -`status` retained as a back-compat alias. -""" -from __future__ import annotations - -import argparse -from types import SimpleNamespace - -import pytest - -from hermes_cli import portal_cli - - -def _args(portal_command): - return SimpleNamespace(portal_command=portal_command) - - -@pytest.mark.parametrize("sub", [None, "", "login"]) -def test_bare_portal_and_login_run_one_shot(monkeypatch, sub): - """`hermes portal`, `hermes portal login` -> one-shot onboarding.""" - calls = {"login": 0, "status": 0} - - def fake_one_shot(config): - calls["login"] += 1 - - def fake_status(args): - calls["status"] += 1 - return 0 - - monkeypatch.setattr( - "hermes_cli.setup._run_portal_one_shot", fake_one_shot - ) - monkeypatch.setattr(portal_cli, "_cmd_status", fake_status) - monkeypatch.setattr(portal_cli, "load_config", lambda: {}) - - rc = portal_cli.portal_command(_args(sub)) - - assert rc == 0 - assert calls["login"] == 1 - assert calls["status"] == 0 - - -@pytest.mark.parametrize("sub", ["info", "status"]) -def test_info_and_status_alias_run_status(monkeypatch, sub): - """`hermes portal info` and the `status` back-compat alias -> status.""" - calls = {"login": 0, "status": 0} - - monkeypatch.setattr( - "hermes_cli.setup._run_portal_one_shot", - lambda config: calls.__setitem__("login", calls["login"] + 1), - ) - - def fake_status(args): - calls["status"] += 1 - return 0 - - monkeypatch.setattr(portal_cli, "_cmd_status", fake_status) - - rc = portal_cli.portal_command(_args(sub)) - - assert rc == 0 - assert calls["status"] == 1 - assert calls["login"] == 0 - - -def test_open_and_tools_dispatch(monkeypatch): - seen = [] - monkeypatch.setattr(portal_cli, "_cmd_open", lambda a: seen.append("open") or 0) - monkeypatch.setattr(portal_cli, "_cmd_tools", lambda a: seen.append("tools") or 0) - - assert portal_cli.portal_command(_args("open")) == 0 - assert portal_cli.portal_command(_args("tools")) == 0 - assert seen == ["open", "tools"] - - -def test_unknown_subcommand_returns_error(capsys): - rc = portal_cli.portal_command(_args("bogus")) - assert rc == 1 - err = capsys.readouterr().err - assert "Unknown portal subcommand" in err - - -def test_login_cancelled_returns_one(monkeypatch): - def boom(config): - raise KeyboardInterrupt - - monkeypatch.setattr("hermes_cli.setup._run_portal_one_shot", boom) - monkeypatch.setattr(portal_cli, "load_config", lambda: {}) - - rc = portal_cli.portal_command(_args(None)) - assert rc == 1 - - -def test_parser_registers_subcommands(): - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest="command") - portal_cli.add_parser(subparsers) - - # Bare `portal` resolves to portal_command with no portal_command set. - ns = parser.parse_args(["portal"]) - assert ns.func is portal_cli.portal_command - assert getattr(ns, "portal_command", None) in (None, "") - - # All documented subcommands parse. - for sub in ("login", "info", "status", "open", "tools"): - ns = parser.parse_args(["portal", sub]) - assert ns.portal_command == sub - - -def test_one_shot_delegates_to_model_flow_nous(monkeypatch): - """`hermes portal` must run the quick-setup Nous flow (login + MODEL PICK + - provider + Tool Gateway), i.e. delegate to `_model_flow_nous` — not the - lighter auth-only path that skipped model selection. - """ - import hermes_cli.setup as setup_mod - - calls = {"model_flow": 0} - - def fake_model_flow(config): - calls["model_flow"] += 1 - - # _model_flow_nous lives in hermes_cli.main and is imported lazily inside - # _run_portal_one_shot, so patch it at the source module. - monkeypatch.setattr("hermes_cli.main._model_flow_nous", fake_model_flow) - # Keep the disk re-sync a no-op so the test never touches real config. - monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) - - setup_mod._run_portal_one_shot({}) - - assert calls["model_flow"] == 1, ( - "`hermes portal` must route through _model_flow_nous so the model " - "picker runs every time (matching quick setup)." - ) - - -@pytest.mark.parametrize("exc", [KeyboardInterrupt, EOFError, SystemExit]) -def test_one_shot_swallows_cancel_and_systemexit(monkeypatch, exc): - """A cancel/abort from the delegated Nous flow must NOT escape and kill the - CLI. `_login_nous` raises SystemExit(130)/(1) on cancel/failure, and the - expired-session re-login path inside `_model_flow_nous` only catches - Exception — so SystemExit could otherwise propagate out. The portal handler - must treat KeyboardInterrupt/EOFError/SystemExit as a graceful cancel. - """ - import hermes_cli.setup as setup_mod - - def boom(config): - raise exc - - monkeypatch.setattr("hermes_cli.main._model_flow_nous", boom) - monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) - - # Must return normally (None), not propagate the exception. - assert setup_mod._run_portal_one_shot({}) is None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e4eb010d425..d3563034648 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4409,6 +4409,37 @@ def _(rid, params: dict) -> dict: return _ok(rid, usage) +@method("credits.view") +def _(rid, params: dict) -> dict: + """Structured Nous credit view for the TUI /credits command. + + Account-independent (a portal fetch gated on "a Nous account is logged in"), + so it works with no live agent / on a resumed session — same as the /usage + credits block. Returns the surface-agnostic CreditsView fields so the TUI can + render a clickable top-up . Fail-open: a portal hiccup or logged-out + account yields {logged_in: false}, never an error the user has to parse. + """ + try: + from agent.account_usage import build_credits_view + + view = build_credits_view() + return _ok( + rid, + { + "logged_in": bool(view.logged_in), + "balance_lines": [ + line for line in view.balance_lines if not line.lstrip().startswith("📈") + ], + "identity_line": view.identity_line, + "topup_url": view.topup_url, + "depleted": bool(view.depleted), + }, + ) + except Exception: + # Fail-open: TUI treats this as "not logged in" and shows the prompt. + return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False}) + + @method("session.status") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) diff --git a/ui-tui/src/__tests__/creditsCommand.test.ts b/ui-tui/src/__tests__/creditsCommand.test.ts new file mode 100644 index 00000000000..6f0f6d59eec --- /dev/null +++ b/ui-tui/src/__tests__/creditsCommand.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { creditsCommands } from '../app/slash/commands/credits.js' +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import type { CreditsViewResponse } from '../gatewayTypes.js' + +// The command opens the top-up URL through this helper on confirm. Mock it so +// the test never shells out to a real browser/`xdg-open` and we can assert the +// success/failure messaging deterministically. +vi.mock('../lib/openExternalUrl.js', () => ({ + openExternalUrl: vi.fn(() => true) +})) + +import { openExternalUrl } from '../lib/openExternalUrl.js' + +const openExternalUrlMock = vi.mocked(openExternalUrl) + +const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')! + +const buildView = (overrides: Partial = {}): CreditsViewResponse => ({ + balance_lines: ['Grant: $9.50 left', 'Top-up: $25.00'], + depleted: false, + identity_line: 'Signed in as ada@example.com', + logged_in: true, + topup_url: 'https://portal.nousresearch.com/billing/topup', + ...overrides +}) + +// Mirror createSlashHandler's real `guarded` wrapper: skip the handler when the +// command is stale OR the response is falsy. Tests stay non-stale, so this is a +// straightforward "run the handler when we got a response" shim. +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +const buildCtx = (rpcResult: CreditsViewResponse) => { + const sys = vi.fn() + const rpc = vi.fn(() => Promise.resolve(rpcResult)) + const guardedErr = vi.fn() + + const ctx = { + gateway: { rpc }, + guarded, + guardedErr, + sid: 'sid-abc', + stale: () => false, + transcript: { page: vi.fn(), panel: vi.fn(), sys } + } + + // Run the command, then await the rpc promise so the .then() handler has + // flushed before assertions — deterministic, no polling/timeouts. + const run = async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + creditsCommand.run('', ctx as any, 'credits') + await rpc.mock.results[0]?.value + // Allow the chained .then() microtask to settle. + await Promise.resolve() + } + + return { ctx, rpc, run, sys } +} + +describe('/credits slash command', () => { + beforeEach(() => { + resetOverlayState() + openExternalUrlMock.mockClear() + openExternalUrlMock.mockReturnValue(true) + }) + + it('renders the balance (including top-up URL) and arms the confirm overlay', async () => { + const view = buildView() + const { rpc, run, sys } = buildCtx(view) + + await run() + + expect(rpc).toHaveBeenCalledWith('credits.view', { session_id: 'sid-abc' }) + + // (a) sys received the balance text including the topup_url + const printed = sys.mock.calls.map(call => call[0]).join('\n') + expect(printed).toContain('💳 Nous credits') + expect(printed).toContain('Grant: $9.50 left') + expect(printed).toContain('Signed in as ada@example.com') + expect(printed).toContain(view.topup_url) + + // (b) confirm overlay set with the expected label + detail + const confirm = getOverlayState().confirm + expect(confirm).toBeTruthy() + expect(confirm?.confirmLabel).toBe('Open top-up in browser') + expect(confirm?.cancelLabel).toBe('Cancel') + expect(confirm?.title).toBe('Add credits?') + expect(confirm?.detail).toBe(view.topup_url) + + // onConfirm opens the URL and reports success back to the transcript + confirm?.onConfirm() + expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url) + expect(sys).toHaveBeenCalledWith( + 'Complete your top-up in the browser — credits will appear in /credits shortly.' + ) + }) + + it('falls back to printing the URL when the browser open is rejected', async () => { + openExternalUrlMock.mockReturnValue(false) + const view = buildView() + const { run, sys } = buildCtx(view) + + await run() + + const confirm = getOverlayState().confirm + expect(confirm).toBeTruthy() + confirm?.onConfirm() + expect(sys).toHaveBeenCalledWith(`Open this URL to top up: ${view.topup_url}`) + }) + + it('does not arm the confirm overlay when there is no top-up URL', async () => { + const view = buildView({ topup_url: null }) + const { run, sys } = buildCtx(view) + + await run() + + const printed = sys.mock.calls.map(call => call[0]).join('\n') + expect(printed).toContain('💳 Nous credits') + expect(getOverlayState().confirm).toBeNull() + }) + + it('shows the not-logged-in message and does NOT arm the confirm overlay', async () => { + const view = buildView({ + balance_lines: [], + identity_line: null, + logged_in: false, + topup_url: null + }) + const { run, sys } = buildCtx(view) + + await run() + + expect(sys).toHaveBeenCalledWith('💳 Not logged into Nous Portal — run /portal to log in.') + expect(getOverlayState().confirm).toBeNull() + expect(openExternalUrlMock).not.toHaveBeenCalled() + }) +}) diff --git a/ui-tui/src/__tests__/turnControllerNotice.test.ts b/ui-tui/src/__tests__/turnControllerNotice.test.ts index 57bfbb2f7be..7ef224aee2a 100644 --- a/ui-tui/src/__tests__/turnControllerNotice.test.ts +++ b/ui-tui/src/__tests__/turnControllerNotice.test.ts @@ -35,7 +35,7 @@ describe('turnController.startMessage — flash-and-yield notices clear on next it('leaves a sticky credits.depleted notice across a new turn', () => { patchUiState({ - notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /usage for balance' } + notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /credits to top up' } }) turnController.startMessage() expect(getUiState().notice?.key).toBe('credits.depleted') diff --git a/ui-tui/src/app/slash/commands/credits.ts b/ui-tui/src/app/slash/commands/credits.ts new file mode 100644 index 00000000000..c653916d2de --- /dev/null +++ b/ui-tui/src/app/slash/commands/credits.ts @@ -0,0 +1,57 @@ +import type { CreditsViewResponse } from '../../../gatewayTypes.js' +import { openExternalUrl } from '../../../lib/openExternalUrl.js' +import { patchOverlayState } from '../../overlayStore.js' +import type { SlashCommand } from '../types.js' + +export const creditsCommands: SlashCommand[] = [ + { + help: 'Show Nous credit balance and top up', + name: 'credits', + run: (_arg, ctx) => { + ctx.gateway + .rpc('credits.view', { session_id: ctx.sid }) + .then( + ctx.guarded(view => { + if (!view.logged_in) { + ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.') + return + } + + const lines = ['💳 Nous credits', ...view.balance_lines] + + if (view.identity_line) { + lines.push('', view.identity_line) + } + + if (view.topup_url) { + lines.push('', `Top up: ${view.topup_url}`) + } + + ctx.transcript.sys(lines.join('\n')) + + const url = view.topup_url + + if (url) { + patchOverlayState({ + confirm: { + cancelLabel: 'Cancel', + confirmLabel: 'Open top-up in browser', + detail: url, + onConfirm: () => { + const ok = openExternalUrl(url) + ctx.transcript.sys( + ok + ? 'Complete your top-up in the browser — credits will appear in /credits shortly.' + : `Open this URL to top up: ${url}` + ) + }, + title: 'Add credits?' + } + }) + } + }) + ) + .catch(ctx.guardedErr) + } + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 353b0a83d1c..7f2d95195f4 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,4 +1,5 @@ import { coreCommands } from './commands/core.js' +import { creditsCommands } from './commands/credits.js' import { debugCommands } from './commands/debug.js' import { opsCommands } from './commands/ops.js' import { sessionCommands } from './commands/session.js' @@ -7,6 +8,7 @@ import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ ...coreCommands, + ...creditsCommands, ...sessionCommands, ...opsCommands, ...setupCommands, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index f2829e1ded4..00a3b458911 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -43,6 +43,16 @@ export interface SlashExecResponse { warning?: string } +// ── Credits / top-up ───────────────────────────────────────────────── + +export interface CreditsViewResponse { + balance_lines: string[] + depleted: boolean + identity_line: string | null + logged_in: boolean + topup_url: string | null +} + export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' }