Merge pull request #4142 from nesquena/stage-4072
Some checks failed
Release & Docker / release (push) Has been cancelled

Release NJ (v0.51.397): wire /credits through WebUI command dispatch (#4071)
This commit is contained in:
nesquena-hermes
2026-06-13 14:28:01 -07:00
committed by GitHub
5 changed files with 180 additions and 2 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.397] — 2026-06-13 — Release NJ (wire /credits through WebUI command dispatch, #4071)
### Fixed
- **The `/credits` slash command now works in the WebUI, not just the CLI (#4071).** `/credits` is added to the allowed-agent-command set and rendered via the shared `agent.account_usage.build_credits_view`, so the browser shows the same Nous credit balance view. It degrades gracefully — a logged-out user gets a "Not logged into Nous" hint, and an import/build failure returns "Couldn't fetch credits right now." instead of erroring. (#4071)
## [v0.51.396] — 2026-06-13 — Release NI (longer timeout for full-history session loads, #4139)
### Fixed

View File

@@ -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.

View File

@@ -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;

View File

@@ -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

View File

@@ -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)