Merge pull request #4142 from nesquena/stage-4072
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user