From 59562e416a54ca5e63e9cf039b9493838ef6188f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 12 Jun 2026 15:25:59 -0400 Subject: [PATCH] fix(#4071): wire /credits through WebUI command dispatch --- api/commands.py | 40 +++++++- static/messages.js | 2 +- tests/test_cli_only_slash_commands.py | 1 + tests/test_commands_endpoint.py | 133 ++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/api/commands.py b/api/commands.py index 0a3ba5fa..66a221a1 100644 --- a/api/commands.py +++ b/api/commands.py @@ -27,7 +27,7 @@ _AGENT_COMMAND_ALIASES = { 'reload_skills': 'reload-skills', 'codex_runtime': 'codex-runtime', } -_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'reload-skills', 'codex-runtime'}) +_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'reload-skills', 'codex-runtime', 'credits'}) _RELOAD_MCP_LOCK = threading.Lock() _RELOAD_SKILLS_LOCK = threading.Lock() _CODEX_RUNTIME_LOCK = threading.Lock() @@ -126,6 +126,8 @@ def execute_agent_command(command: str) -> str: return _run_reload_skills_command() if canonical == 'codex-runtime': return _run_codex_runtime_command(arg_string) + if canonical == 'credits': + return _run_credits_command() raise KeyError(canonical) @@ -256,6 +258,42 @@ def _run_reload_skills_command() -> str: return "\n".join(lines) +def _run_credits_command() -> str: + """Render Hermes' shared credits view for the WebUI slash-command path.""" + try: + from agent.account_usage import build_credits_view + except Exception: + logger.warning("Failed to import credits view runtime", exc_info=True) + return "Couldn't fetch credits right now." + + try: + view = build_credits_view(markdown=True) + except Exception: + logger.warning("Failed to build /credits view", exc_info=True) + return "Couldn't fetch credits right now." + + if not getattr(view, "logged_in", False): + return "Not logged into Nous. Run `hermes auth login nous` in Hermes CLI, then try /credits again." + + lines = ["💳 **Nous credits**"] + for line in tuple(getattr(view, "balance_lines", ()) or ()): + if str(line).lstrip().startswith("📈"): + continue + lines.append(str(line)) + + identity_line = str(getattr(view, "identity_line", "") or "").strip() + if identity_line: + lines.append("") + lines.append(identity_line) + + topup_url = str(getattr(view, "topup_url", "") or "").strip() + if topup_url: + lines.append("") + lines.append(f"Top up: {topup_url}") + lines.append("Complete your top-up in the browser; credits will appear in /credits shortly.") + return "\n".join(lines) + + def execute_plugin_command(command: str) -> str: """Execute a plugin-registered slash command and return printable output. diff --git a/static/messages.js b/static/messages.js index 4de862f6..86f4f259 100644 --- a/static/messages.js +++ b/static/messages.js @@ -792,7 +792,7 @@ const _sessionTitleProvisionalBySid = new Map(); // their canonical command is registered on the backend (for example // /reload-mcp). Keep this intentionally narrow and include underscore variants // observed by users so typing either form still routes through executeAgentCommand. -const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'reload-skills', 'reload_skills', 'codex-runtime', 'codex_runtime']); +const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'reload-skills', 'reload_skills', 'codex-runtime', 'codex_runtime', 'credits']); function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){ if(!S||!S.busy||compressionRunning) return false; diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 58a2a3bd..d58aa676 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -266,6 +266,7 @@ def test_reload_mcp_reload_skills_and_codex_runtime_webui_intercept_aliases_are_ assert "'reload_skills'" in MESSAGES_JS assert "'codex-runtime'" in MESSAGES_JS assert "'codex_runtime'" in MESSAGES_JS + assert "'credits'" in MESSAGES_JS assert "if(_agentCmd&&_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" not in MESSAGES_JS diff --git a/tests/test_commands_endpoint.py b/tests/test_commands_endpoint.py index ef9b4b2d..f80208a5 100644 --- a/tests/test_commands_endpoint.py +++ b/tests/test_commands_endpoint.py @@ -1,4 +1,5 @@ """Tests for GET /api/commands -- exposes hermes-agent COMMAND_REGISTRY.""" +import io import json import urllib.error import urllib.request @@ -71,6 +72,26 @@ def _install_fake_skill_commands(monkeypatch, reload_skills): return skill_commands +def _install_fake_account_usage(monkeypatch, *, view=None, exc=None): + import sys + + agent_pkg = sys.modules.get("agent") or ModuleType("agent") + agent_pkg.__path__ = [] + account_usage = ModuleType("agent.account_usage") + + def build_credits_view(*, markdown=False, timeout=10.0): + assert markdown is True + if exc is not None: + raise exc + return view + + account_usage_any = cast(Any, account_usage) + account_usage_any.build_credits_view = build_credits_view + monkeypatch.setitem(sys.modules, "agent", agent_pkg) + monkeypatch.setitem(sys.modules, "agent.account_usage", account_usage) + return account_usage + + def _get(path): """GET helper -- returns parsed JSON or raises HTTPError.""" with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r: @@ -182,6 +203,118 @@ def test_commands_exec_runs_reload_skills_alias(): assert isinstance(body['output'], str) +def test_credits_command_renders_shared_credits_view(monkeypatch): + """`/credits` should reuse the shared Hermes credits view in WebUI output.""" + _install_fake_account_usage( + monkeypatch, + view=SimpleNamespace( + logged_in=True, + balance_lines=("📈 **Balance**", "- Subscription credits: $12.34", "- Top-up credits: $1.23"), + identity_line="Topping up as rod@example.com / org Nous", + topup_url="https://portal.nous.example/topup", + ), + ) + + from api.commands import execute_agent_command + + output = execute_agent_command('/credits') + + assert output == "\n".join( + [ + "💳 **Nous credits**", + "- Subscription credits: $12.34", + "- Top-up credits: $1.23", + "", + "Topping up as rod@example.com / org Nous", + "", + "Top up: https://portal.nous.example/topup", + "Complete your top-up in the browser; credits will appear in /credits shortly.", + ] + ) + + +def test_commands_exec_routes_credits_through_agent_dispatch(monkeypatch): + """`/credits` should go through the POST route's agent-command path, not the plugin fallback.""" + + class _FakeHandler: + def __init__(self, body_bytes: bytes): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.rfile = io.BytesIO(body_bytes) + self.headers = {"Content-Length": str(len(body_bytes))} + self.request = None + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def json_body(self): + return json.loads(bytes(self.body).decode("utf-8")) + + import api.commands as commands + from api import routes + + calls = [] + + def _fake_execute_agent_command(command): + calls.append(command) + return "credits ok" + + def _fake_execute_plugin_command(command): + raise AssertionError(f"plugin path should not run for {command!r}") + + monkeypatch.setattr(commands, "execute_agent_command", _fake_execute_agent_command) + monkeypatch.setattr(commands, "execute_plugin_command", _fake_execute_plugin_command) + + raw = json.dumps({"command": "/credits"}).encode("utf-8") + handler = _FakeHandler(raw) + routes.handle_post(handler, SimpleNamespace(path="/api/commands/exec", query="")) + + assert calls == ["/credits"] + assert handler.status == 200 + assert handler.json_body() == {"output": "credits ok"} + + +def test_credits_command_returns_not_logged_in_message(monkeypatch): + """`/credits` should degrade to a friendly login hint when Nous auth is absent.""" + _install_fake_account_usage( + monkeypatch, + view=SimpleNamespace( + logged_in=False, + balance_lines=(), + identity_line=None, + topup_url=None, + ), + ) + + from api.commands import execute_agent_command + + output = execute_agent_command('/credits') + + assert output == "Not logged into Nous. Run `hermes auth login nous` in Hermes CLI, then try /credits again." + + +def test_credits_command_fail_opens_on_runtime_error(monkeypatch): + """`/credits` failures should return a short user-facing message, not 500s.""" + _install_fake_account_usage(monkeypatch, exc=RuntimeError("portal timeout")) + + from api.commands import execute_agent_command + + output = execute_agent_command('/credits') + + assert output == "Couldn't fetch credits right now." + + def test_codex_runtime_command_uses_shared_switch_and_persists(monkeypatch, tmp_path): """`/codex-runtime` executes through the same shared switch as CLI/gateway.""" calls = _install_fake_codex_runtime_switch(monkeypatch)